Merge branch 'main' into main

This commit is contained in:
greenart7c3
2024-02-28 10:59:39 -03:00
committed by GitHub
555 changed files with 18497 additions and 4072 deletions
+5
View File
@@ -228,3 +228,8 @@ jobs:
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip asset_content_type: application/zip
- name: Drafts a description for the release
uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.10" /> <option name="version" value="1.9.22" />
</component> </component>
</project> </project>
+84 -42
View File
@@ -1,22 +1,42 @@
# Amethyst: Nostr client for Android <div align="center">
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/> <a href="https://amethyst.social">
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
</a>
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting. # Amethyst
## Nostr Client for Android
Join the social network you control.
[![GitHub downloads](https://img.shields.io/github/downloads/vitorpamplona/amethyst/total?label=Downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://github.com/vitorpamplona/amethyst/releases)
[![PlayStore downloads](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.vitorpamplona.amethyst%26gl%3DUS%26hl%3Den%26l%3DPlayStore%26m%3D%24shortinstalls)](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
[![Last Version](https://img.shields.io/github/release/vitorpamplona/amethyst.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/vitorpamplona/amethyst)
[![JitPack version](https://jitpack.io/v/vitorpamplona/amethyst.svg)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[<img src="./docs/design/obtainium.png" [<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum" alt="Get it on Obtaininum"
height="80">](https://github.com/ImranR98/Obtainium) height="70">](https://github.com/ImranR98/Obtainium)
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub" [<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
height="80">](https://github.com/vitorpamplona/amethyst/releases) height="70">](https://github.com/vitorpamplona/amethyst/releases)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" [<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid" alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/) height="70">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" [<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play" alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst) height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
# Current Features </div>
## Supported Features
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">
- [x] Events / Relay Subscriptions (NIP-01) - [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02) - [x] Follow List (NIP-02)
@@ -27,7 +47,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] WebBrowser Signer (NIP-07, Not applicable) - [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08) - [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09) - [x] Event Deletion (NIP-09)
- [x] Replies, mentions, Threads and Notifications (NIP-10) - [x] Replies, mentions, Threads, and Notifications (NIP-10)
- [x] Relay Information Document (NIP-11) - [x] Relay Information Document (NIP-11)
- [x] Generic Tag Queries (NIP-12) - [x] Generic Tag Queries (NIP-12)
- [x] Proof of Work Display (NIP-13) - [x] Proof of Work Display (NIP-13)
@@ -59,6 +79,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Nostr Connect (NIP-46) - [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47) - [x] Wallet Connect API (NIP-47)
- [ ] Proxy Tags (NIP-48, Not applicable) - [ ] Proxy Tags (NIP-48, Not applicable)
- [x] Private key encryption for import/export (NIP-49)
- [x] Online Relay Search (NIP-50) - [x] Online Relay Search (NIP-50)
- [x] Lists (NIP-51) - [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52) - [ ] Calendar Events (NIP-52)
@@ -69,6 +90,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Zaps (NIP-57) - [x] Zaps (NIP-57)
- [x] Private Zaps - [x] Private Zaps
- [x] Zap Splits (NIP-57) - [x] Zap Splits (NIP-57)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD) - [x] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58) - [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65) - [ ] Relay List Metadata (NIP-65)
@@ -79,13 +101,13 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Highlights (NIP-84) - [x] Highlights (NIP-84)
- [x] Recommended Application Handlers (NIP-89) - [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90) - [ ] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
- [x] Verifiable file URLs (NIP-94) - [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95) - [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft) - [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] HTTP Auth (NIP-98) - [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99) - [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft) - [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Gift Wraps & Seals (NIP-59/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft) - [x] Versioned Encrypted Payloads (NIP-44/Draft)
- [x] Audio Tracks (zapstr.live) (Kind:31337) - [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Google and Unified Push) - [x] Push Notifications (Google and Unified Push)
@@ -101,40 +123,50 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Workspaces - [ ] Workspaces
- [ ] Infinity Scroll - [ ] Infinity Scroll
## Privacy and Information Permanence
Relays know your IP address, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) except for Private Zaps and Private DMs. While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN or Tor) from trackers online.
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
Information shared on Nostr can be re-broadcasted to other servers and should be assumed permanent for privacy purposes. There is no way to guarantee the deletion of any content once posted.
# Development Overview # Development Overview
## Overall Architecture This repository is split between Amethyst and Quartz:
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
This is a native Android app made with Kotlin and Jetpack Compose. The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph. 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 The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData and Flow 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 They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes
sees different Events, the Datasource classes are used to receive more information about those particular Events. are updated 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, 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. 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. 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. Lastly, the user's account information (private key/pub key) is stored in the Android KeyStore for security.
## Setup ## Setup
Make sure to have the following pre-requisites installed: Make sure to have the following pre-requisites installed:
1. Java 17 1. Java 17+
2. Android Studio 2. Android Studio
3. Android 8.0+ Phone or Emulation setup 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 ```bash
git clone https://github.com/vitorpamplona/amethyst.git git clone https://github.com/vitorpamplona/amethyst.git
``` ```
Use one of the Android Studio builds to install and run the app in your device or a simulator. Use an Android Studio build action to install and run the app on your device or a simulator.
## Building ## Building
Build the app: Build the app:
@@ -166,7 +198,7 @@ For the Play build:
./gradlew installPlayDebug ./gradlew installPlayDebug
``` ```
## How to Deploy ## Deploying
1. Generate a new signing key 1. Generate a new signing key
``` ```
@@ -183,25 +215,37 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
5. Tag the commit with `v{x.x.x}` 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. 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 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 ## Using the Quartz library
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. Setup [JitPack.io](https://jitpack.io/#vitorpamplona/amethyst/v0.84.3) to your build file
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs. Add `maven { url 'https://jitpack.io' }` to settings.gradle at the end of repositories:
# DM Privacy # ```gradle
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other. dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
```
# Visibility & Permanence of Your Content on nostr Add the dependency
## Information Visibility ##
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 ## ```gradle
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. implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3')
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are 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.
# Screenshots # Screenshots
@@ -209,11 +253,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------| |-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) | | ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
# Contributing # Contributors
[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"> <a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" /> <img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
@@ -221,6 +261,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
# MIT License # MIT License
<pre>
Copyright (c) 2023 Vitor Pamplona Copyright (c) 2023 Vitor Pamplona
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -240,3 +281,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
</pre>
+14 -10
View File
@@ -12,8 +12,9 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 348 versionCode 358
versionName "0.83.7" versionName "0.84.3"
buildConfigField "String", "RELEASE_NOTES_ID", "\"4d5a05aec61d8798f30f76b2efab81b98d75a03f935fb82823a1080bd56473cd\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -26,7 +27,7 @@ android {
'cy-rGB', 'cy-rGB',
'da-rDK', 'da-rDK',
'de', 'de',
'el-rGB', 'el-rGR',
'en-rGB', 'en-rGB',
'eo', 'eo',
'es', 'es',
@@ -141,13 +142,15 @@ android {
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion "1.5.3" // Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
} }
packagingOptions { packagingOptions {
resources { resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}' excludes += '/META-INF/{AL2.0,LGPL2.1}'
} }
exclude '**/libscrypt.dylib'
} }
lint { lint {
@@ -161,6 +164,7 @@ android {
dependencies { dependencies {
implementation project(path: ':quartz') implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation "androidx.core:core-ktx:$core_ktx_version" implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-compose:1.8.2'
implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui:$compose_ui_version"
@@ -181,7 +185,7 @@ dependencies {
// Adaptive Layout / Two Pane // Adaptive Layout / Two Pane
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}" 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 // Lifecycle
@@ -191,7 +195,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images // Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.3' implementation 'net.engawapg.lib:zoomable:1.6.0'
// Biometrics // Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -228,7 +232,7 @@ dependencies {
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// For QR generation // 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' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown // Markdown
@@ -251,7 +255,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.2' playImplementation 'com.google.mlkit:translate:17.0.2'
// PushNotifications // PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.7.0') playImplementation platform('com.google.firebase:firebase-bom:32.7.2')
playImplementation 'com.google.firebase:firebase-messaging-ktx' playImplementation 'com.google.firebase:firebase-messaging-ktx'
//PushNotifications(FDroid) //PushNotifications(FDroid)
@@ -276,8 +280,8 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.9' testImplementation 'io.mockk:mockk:1.13.9'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02' androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02' androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
+5
View File
@@ -33,6 +33,11 @@
# JNA For Libsodium # JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; } -keep class com.goterl.lazysodium.** { *; }
# libscrypt
-keep class com.lambdaworks.codec.** { *; }
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out # JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; } -keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; } -keep class com.sun.jna.NativeMapped { *; }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -0,0 +1,95 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import junit.framework.TestCase.assertEquals
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class OkHttpOtsTest {
val otsEvent = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqCiiW0FlsU9lqK5f1A+cL6CGJ1Ah4V/A1yNJY/stUE3wECJz6ng/QxU5Z6xwaMx97qkI//AQqJv8bEMrGTplGWRv5qm4DgjxIHkcQqzpL0Fjr9VBAAijDe0IsQYpOhw1SIjZIgQa6i16CPEEZck7CvAIxR0AloJzCZoAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEOwPtjIkKI1hmtv9t1kuxZcI8QRlyTsK8Ahl0wrCSggZzgCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEE1dVGa8JCuf2ek0c5ybDKII8SCBoVz8Sal45Kd1O8STWIGJTcl5JPtAZBZitqk3BE9MqAjxBGXJOwrwCHoGVgAZi9q9AIPf4w0u+QyOKShodHRwczovL2Zpbm5leS5jYWxlbmRhci5ldGVybml0eXdhbGwuY29t8BAFZFXFYg7DJJ0OzjmJ0FKWCPEEZck7CvAI0M49IcBR5bf/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPEgoE3IfYTmxxo4W/x/QYp/NGX6Wu93gSQkwbpjpOhZORcI8SDWLLurVQaXHdUuwivCfTfuxYCaq+AzypSGqLDAVocrEgjwIBfgjta16y13Gp4etQOCa9YiKEcM+/9AieG/vZolr3IDCPAgMR2zFCb384CEi8tVuI2fHgLT3I9zpe7oqJTzCcEqxWEI8SAJSdgeeosr7IxdOt8r7f0ipWc8FI6GAhgep8zSRgWikAjxIGxYmtCsC79Tx4z4YsT1WuMo+ycMkwhGQsQltF597cchCPAgCLrBf9vR1Aex6yY+vSkXAvLjMKdMqM/a1g8zNPwLeJcI8SBDCbTk4CczTuiIyZeUyYRVh31BZdjaSd2nU/pBQxu+6QjwIOwqE9/WqGC6CHH0i+tr7edvYX5PstSDf08KmnMqsqCoCPEgQIdEBg358vfek3Qjfyrgl51iCU6WUWmThsGLPDTcB0QI8SAX4dp64iI8pBx+zBqAQwUN6XgZ1cEfT8+2vha/9I1vzwjxWQEAAAAB8YJxvxJJth8OnxIV6UOXveIZAcJPTHcAkWgnucpuYqYAAAAAAP3///8CRhoMAAAAAAAWABTUUZK82uAvbU3vVyaaPIOddZBicwAAAAAAAAAAImog8ARCqgwACAjwIB9TcMDLhzgeS1Uw647lNvCfWECkkUvfrrOe6nay0sGdCAjwICYfs90sbPggoMICyOHGYbmOzop2L9mlnh4xqiBLY7yPCAjxINDiVWOBHnRmGJleQdB9myvJAJbNJ9kciZlTOkgJy89mCAjwIHfxqDLdwycj1Vtyth2CaSDdLQwiey9oV6Xov4stLpWNCAjxIGurJYpKJnKp9+y7MAdC+gXgHOiAu5P3RRUFW9l5hGaCCAjxICXqs9hdY0QMP/MNeqlt6s7xaIYtEXZ1CLvou5gaZNEICAjxIHdYxVeI76NXbT2zHcv6lw+v819Ooib7KWxc1GAsiX2fCAjwICPWdi3uBXOlIdmYi+V9C7wAqLyGE4DMoHD+GtvLizqiCAjwIOF2vENtWN5okEMMS+JSf1SGTY9yYP9j0JjXLbC1s+N1CAjxINWsgCtsPxhRNbe372k8/20WDbiL9e8934hGF256DvRECAjwII3mi+Li06j10ORxg0dYMkcsyGb115Jiqq1YEV3K/u+aCAgABYiWDXPXGQEDw9Qy\",\"created_at\":1707690688,\"id\":\"759f9da5846e936fab06766a524b36ba71c03bbc69ad0944fb8ee4bb1f3dd705\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"07c7896c8cbb97b5d7483097590c9d31b73f35c1ad9e752002bb5c1776cbd852e1d32704333d6930c9bc3e40f8b899a1f2e9f91cc3bf797d86acdecba7792576\",\"tags\":[[\"e\",\"a828a25b4165b14f65a8ae5fd40f9c2fa086275021e15fc0d7234963fb2d504d\"],[\"p\",\"595ca8eaace5899cb6ab7e2542bfc972136376f2eabc09287f1857eb8f167e53\"],[\"alt\",\"NIP-03 time stamp\"]]}"
val otsEvent2 = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwENyCNtiEN98IzIZgEu3cl6YI//AQ6TkSRd3BTGhDHCK1KkJc+AjxIAHaizG++NNL3Vm13BJrIhT7Br6tEYpb0TVRGaadgiUMCPAgOSDREH9v1Y50UHu79LfC4Lcd9WklQJzRQpw+Unb/pyII8QRltDD58AgqrxfAVrLw7QCD3+MNLvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQQMq/CLpGwY60nmddPS7OVgjxIDKxqd9nl+Mej41vP52Wd7gv7004r3n1rFGDObS8icRvCPAgH9TB/kwvXJEEw+h9Ce6fLaI3MORjtTEge0GbAefT6W4I8QRltDD58AhRcoU3gAo/swCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wECWtWsKo0uvSr8BYonjs3DEI8CBlsh2ng1Spl0K4oStYElGuMJsjd2uo5nXB+apo5A7ipwjxIM8oxynBwNA+QS/X7Ebtl1kyhFgfoOQioASNfCBzZ4gaCPEEZbQw+fAId6Yd5cw5gioAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb23wEJmPzXQbxv0AFTIyjTWjMskI8CAurbkrfrBtlinZXSDxj+m/oIkze57hGjTSxu1Xs87XYQjwIPk/LMD0zIgKoEE2dfeoYrrdHuO6dwmghTwUFajH2QzkCPEEZbQw+fAIE+Pq1/Wmdpj/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPAgB2CbqkV7VpjRKIl3Ea6cBmB/EHcSN/YCgcc1E+mc07QI8CAfpkZ2Hh4Rukz3x4il3tZqQtlDlbna+I6so2t2YSEmMQjwIJOv32jbsMa2HJwpleRCKLEhgYOoHCSfpv1ZO0YNNNFsCPAgLMM7eFfCjokQfU4gdU5WpG/wBLkO9lDRF0GktL6ujt8I8SCRxJ0bC1PQ8qFmI/1jh8AS5d1/6VRJNMt1Hz41QmNr3QjwIBmgrKBF+OZ3y+XOMv2E7IZ4WwLr2u2H+ehsBfy7cPlICPEgm4ZMCSXzZVWu40d+zk2edaur6KOauo8X7V2KaFBR1VoI8SBKVVOiyq6IFqGn/15kLwk7L8upMAIZ0znjhYxYqSTQCQjwIMBD1twPZ33GxbwTiuOCeJPkoP++6R2wYpCii8UBTdgwCPAg84VkgMXwrt2xxRoeC1/6CtsFctki+w3m8Rs5/6g/IhEI8CCnzZQDhJyicX7bS7U8PMUObuC9Y4TXe+4THoXBMMXkxwjxIMZ0oAvshpcwowR3qPEDbwKZ6B4NPSU4Hz/+4PnD74gnCPAgIfLZEKqAvkMNXfakXoNq1UVqGSzL4Z86z5GzUfbvw8UI8SC8KoIeLvjd4vJ/xhNVphakPRd80YKeNkeYEuVH8k2EtAjwIPRtinLLxzt8iuw0XZtpTDzEstZOTNYVm+Bi3fEzdeIuCPFZAQAAAAE8vsasINN5DKon0KakX2HNdCB126ZLKXrw4PfvyEfqbwAAAAAA/f///wLPGw8AAAAAABYAFCvgxlh6msa4ZOtgvlc5KiZCx7IvAAAAAAAAAAAiaiDwBKygDAAICPEgB/2DJ3s6gMky/PceGZocTFRXjZUiCCAhHGYwQk/8yrUICPEgDuSd6+PJHUMuEHyLcKFxw7xfvRHRfInjkV3/Zy3BxqAICPEgZDgQ+4VXzlOIkGoO8EVxDgs2cWaeh4EEiaqa/y50gKAICPAgFI+zIuYcMF69GmPQVsXa9oy8eng7MeRZdIxArQyeX3oICPEgX5HYIuImpiSTEapgEssEW4l+W+4aRfNCG3pZf7z0hCoICPEgPkAbOSjFdtS4NT7MXgMYVQoQhI1JZtdFxUu4J3NTt7IICPEguW3qyuyGjctu5d9rM9P9ZCs/ZK4vAc+z21b9ygklgWAICPEgu4e2645xtvGhI1Zzuiv23vRhwE8uC9vj1TAgNg/C8UcICPAgwoQX8X0nY4HoQLRsJ0z8JCWQDzRh2iL2QXEb8z3gbjIICPAgzTwlPRtStsLJWhz3Q/0l8tMnrPSHVuh+zCiGk95dW2MICPAgGcBEuYZyzFNapHOfnJ9Q515QzO2VbIRhlVI0vIhd4jwICPAgidRMoM2pA+KmVJenVrLcbollsbUg9lL9bmv1C1dSxswICPAgZinGakwhbHdanTaRJeBkEUlbhfNokvj8b5KneyG+wzIICAAFiJYNc9cZAQOtwTI=\",\"created_at\":1706324334,\"id\":\"2ad074ddb7724eb13b4244b49cf2321b1057f37fdf8ce102e6329b839cf763a9\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"ad7274bb32ba9e9cfdbd52f4887e8a2fda1047c75a7185b2ab7ff254ebac14ed48a2b60737494d655e24c9400eeeec7e29293a77bfcaafaecd94b350c9a2c22b\",\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"p\",\"c31e22c3715c1bde5608b7e0d04904f22f5fc453ba1806d21c9f2382e1e58c6c\"],[\"alt\",\"NIP-03 time stamp\"]]}"
val otsPendingEvent = "{\"id\":\"12fa15ad4b4cf9dc5940389325b69b93c5c1f59c049c701ee669b275299fdaf1\",\"pubkey\":\"dcaa6c8a2f47b6fef4a34b20e8843c59dbe7c5f07a402338c09fd147dd01d22b\",\"created_at\":1708877521,\"kind\":1040,\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"alt\",\"Opentimestamps Attestation\"]],\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwELidvzr0usf55CkpKf6OABQI//AQK3sWd2tq+7KO8YNJIARJugjxBGXbZtPwCL0H4/7GL5+SAIPf4w0u+QyOLCtodHRwczovL2JvYi5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQPDZsJgN1TnJXoUzlsgo93wjwIIfBc7LUqkCbC1BLZRZ+6LXztK50UdH5xe7fn40bupkrCPEEZdtm0/AI0CADXN5ZIncAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ/AQcELcSrE04cuGKlZQf2LeVwjwILUDSf9vK2GaefKTpn/LV2oUsQaA5WbqaP3C+1ZxQfRNCPEEZdtm0/AIbCtb+yRXFqUAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20=\",\"sig\":\"f6854c0228c15c08aeb70bbabe9ed87bbb7289fab31b13cabac15138bb71179553e06080b83f4a813fbdaf614f63293beea3fc73fe865da6551193fa4d38de04\"}"
val otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6"
@Before
fun setup() {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
}
@Test
fun verifyNostrEvent() {
val ots = Event.fromJson(otsEvent) as OtsEvent
println(ots.info())
assertEquals(1707688818L, ots.verify())
}
@Test
fun verifyNostrEvent2() {
val ots = Event.fromJson(otsEvent2) as OtsEvent
println(ots.info())
assertEquals(1706322179L, ots.verify())
}
@Test
fun verifyNostrPendingEvent() {
val ots = Event.fromJson(otsPendingEvent) as OtsEvent
println(ots.info())
assertEquals(null, ots.verify())
}
@Test
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
var ots: OtsEvent? = null
val countDownLatch = CountDownLatch(1)
val otsFile = OtsEvent.stamp(otsEvent2Digest)
OtsEvent.create(otsEvent2Digest, otsFile, signer) {
ots = it
countDownLatch.countDown()
}
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(ots!!.toJson())
println(ots!!.info())
// Should not be valid because we need to wait for confirmations
assertEquals(null, ots!!.verify())
}
}
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -60,6 +61,7 @@ class PushMessageReceiver : MessagingReceiver() {
try { try {
parseMessage(messageStr)?.let { receiveIfNew(it) } parseMessage(messageStr)?.let { receiveIfNew(it) }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message could not be parsed: ${e.message}") Log.d(TAG, "Message could not be parsed: ${e.message}")
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.CancellationException
object PushNotificationUtils { object PushNotificationUtils {
var hasInit: Boolean = false var hasInit: Boolean = false
@@ -36,6 +37,7 @@ object PushNotificationUtils {
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
+5 -2
View File
@@ -3,8 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<queries> <queries>
<package android:name="org.torproject.android"/> <package android:name="org.torproject.android"/>
<package android:name="com.greenart7c3.nostrsigner"/> <intent>
<package android:name="com.greenart7c3.nostrsigner.debug"/> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
</queries> </queries>
<!-- Doesn't require a camera --> <!-- Doesn't require a camera -->
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -26,9 +26,14 @@ import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy import android.os.StrictMode.VmPolicy
import android.util.Log import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader import coil.ImageLoader
import coil.disk.DiskCache import coil.disk.DiskCache
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.playback.VideoCache import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -52,7 +57,7 @@ class Amethyst : Application() {
newCache newCache
} }
private val imageCache: DiskCache by lazy { val coilCache: DiskCache by lazy {
DiskCache.Builder() DiskCache.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache")) .directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2) .maxSizePercent(0.2)
@@ -64,6 +69,8 @@ class Amethyst : Application() {
super.onCreate() super.onCreate()
instance = this instance = this
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(), ThreadPolicy.Builder().detectAll().penaltyLog().build(),
@@ -84,7 +91,11 @@ class Amethyst : Application() {
} }
fun imageLoaderBuilder(): ImageLoader.Builder { fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { imageCache } return ImageLoader.Builder(applicationContext).diskCache { coilCache }
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
return EncryptedStorage.preferences(instance, npub)
} }
companion object { companion object {
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -20,10 +20,12 @@
*/ */
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
object EncryptedStorage { class EncryptedStorage {
companion object {
private const val PREFERENCES_NAME = "secret_keeper" private const val PREFERENCES_NAME = "secret_keeper"
// returns the preferences for each account or a global file if null. // returns the preferences for each account or a global file if null.
@@ -31,21 +33,22 @@ object EncryptedStorage {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
} }
fun preferences(npub: String? = null): EncryptedSharedPreferences { fun preferences(
val context = Amethyst.instance applicationContext: Context,
npub: String? = null,
): EncryptedSharedPreferences {
val masterKey: MasterKey = val masterKey: MasterKey =
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
val preferencesName = prefsFileName(npub)
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, applicationContext,
preferencesName, prefsFileName(npub),
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) as EncryptedSharedPreferences ) as EncryptedSharedPreferences
} }
} }
}
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -33,17 +33,18 @@ import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType 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.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.encoders.toNpub
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -113,6 +115,8 @@ private object PrefKeys {
const val SIGNER_PACKAGE_NAME = "signer_package_name" const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val NEW_POST_DRAFT = "draft_new_post" const val NEW_POST_DRAFT = "draft_new_post"
const val DRAFT_REPLY_POST = "draft_reply_post" const val DRAFT_REPLY_POST = "draft_reply_post"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val PENDING_ATTESTATIONS = "pending_attestations"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings" const val SHARED_SETTINGS = "shared_settings"
@@ -234,7 +238,7 @@ object LocalPreferences {
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else { } else {
return EncryptedStorage.preferences(npub) return Amethyst.instance.encryptedStorage(npub)
} }
} }
@@ -332,12 +336,18 @@ object LocalPreferences {
PrefKeys.LAST_READ_PER_ROUTE, PrefKeys.LAST_READ_PER_ROUTE,
Event.mapper.writeValueAsString(account.lastReadPerRoute), Event.mapper.writeValueAsString(account.lastReadPerRoute),
) )
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
if (account.showSensitiveContent == null) { if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT) remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else { } else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
} }
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(account.pendingAttestations),
)
} }
.apply() .apply()
} }
@@ -370,6 +380,7 @@ object LocalPreferences {
return try { 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) { } catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w( Log.w(
"LocalPreferences", "LocalPreferences",
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
@@ -544,6 +555,7 @@ object LocalPreferences {
} }
?: Nip96MediaServers.DEFAULT[0] ?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Failed to decode saved File Server", e) Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace() e.printStackTrace()
Nip96MediaServers.DEFAULT[0] Nip96MediaServers.DEFAULT[0]
@@ -552,9 +564,10 @@ object LocalPreferences {
val zapPaymentRequestServer = val zapPaymentRequestServer =
try { try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47URI?>(it) Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w( Log.w(
"LocalPreferences", "LocalPreferences",
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
@@ -575,6 +588,27 @@ object LocalPreferences {
} }
} }
} catch (e: Throwable) { } 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 pendingAttestations =
try {
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
println("Decoding Attestation List: " + it)
if (it != null) {
Event.mapper.readValue<Map<HexKey, String>>(it)
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w( Log.w(
"LocalPreferences", "LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
@@ -590,6 +624,7 @@ object LocalPreferences {
} }
?: mapOf() ?: mapOf()
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w( Log.w(
"LocalPreferences", "LocalPreferences",
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
@@ -604,7 +639,7 @@ object LocalPreferences {
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false) val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false) val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050) val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
val showSensitiveContent = val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
@@ -622,6 +657,7 @@ object LocalPreferences {
} }
?: mapOf() ?: mapOf()
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w( Log.w(
"LocalPreferences", "LocalPreferences",
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
@@ -644,6 +680,8 @@ object LocalPreferences {
NostrSignerInternal(keyPair) NostrSignerInternal(keyPair)
} }
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val account = val account =
Account( Account(
keyPair = keyPair, keyPair = keyPair,
@@ -671,6 +709,8 @@ object LocalPreferences {
warnAboutPostsWithReports = warnAboutReports, warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam, filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute, lastReadPerRoute = lastReadPerRoute,
hasDonatedInVersion = hasDonatedInVersion,
pendingAttestations = pendingAttestations ?: emptyMap(),
) )
// Loads from DB // Loads from DB
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -30,7 +30,7 @@ import coil.decode.SvgDecoder
import coil.size.Precision import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache 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.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -83,7 +84,7 @@ class ServiceManager {
val myAccount = account val myAccount = account
// Resets Proxy Use // Resets Proxy Use
HttpClient.start(account?.proxy) HttpClientManager.setDefaultProxy(account?.proxy)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader { Coil.setImageLoader {
Amethyst.instance Amethyst.instance
@@ -96,7 +97,7 @@ class ServiceManager {
} }
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
} // .logger(DebugLogger()) } // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() } .okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT) .precision(Precision.INEXACT)
.respectCacheHeaders(false) .respectCacheHeaders(false)
.build() .build()
@@ -126,6 +127,7 @@ class ServiceManager {
try { try {
it.npub.bechToBytes().toHexKey() it.npub.bechToBytes().toHexKey()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
null null
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -31,6 +31,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
@@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent
@@ -74,6 +76,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP24Factory import com.vitorpamplona.quartz.events.NIP24Factory
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.Price
@@ -96,6 +99,7 @@ import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toPersistentSet import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -170,7 +174,7 @@ class Account(
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), var defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: Nip47URI? = null, var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false, var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
var hideNIP24WarningDialog: Boolean = false, var hideNIP24WarningDialog: Boolean = false,
@@ -181,10 +185,10 @@ class Account(
var warnAboutPostsWithReports: Boolean = true, var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true, var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(), var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var hasDonatedInVersion: Set<String> = setOf<String>(),
var pendingAttestations: Map<HexKey, String> = mapOf<HexKey, String>(),
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
) { ) {
// Uses a single scope for the entire application.
val scope = Amethyst.instance.applicationIOScope
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf() var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
data class PaymentRequest( data class PaymentRequest(
@@ -727,7 +731,7 @@ class Account(
zapResponseEvent.response(signer, onReady) zapResponseEvent.response(signer, onReady)
} }
fun calculateIfNoteWasZappedByAccount( suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?, zappedNote: Note?,
onWasZapped: () -> Unit, onWasZapped: () -> Unit,
) { ) {
@@ -891,6 +895,38 @@ class Account(
} }
} }
suspend fun updateAttestations() {
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
pendingAttestations.toMap().forEach { pair ->
val newAttestation = OtsEvent.upgrade(pair.value, pair.key)
if (pair.value != newAttestation) {
OtsEvent.create(pair.key, newAttestation, signer) {
LocalCache.justConsume(it, null)
Client.send(it)
pendingAttestations = pendingAttestations - pair.key
}
}
}
}
fun hasPendingAttestations(note: Note): Boolean {
val id = note.event?.id() ?: note.idHex
return pendingAttestations.get(id) != null
}
fun timestamp(note: Note) {
if (!isWriteable()) return
val id = note.event?.id() ?: note.idHex
pendingAttestations = pendingAttestations + Pair(id, OtsEvent.stamp(id))
saveable.invalidateData()
}
fun follow(user: User) { fun follow(user: User) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1166,7 +1202,7 @@ class Account(
Client.send(signedEvent, relayList = relayList) Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id] return LocalCache.getNoteIfExists(signedEvent.id)
} }
fun consumeNip95( fun consumeNip95(
@@ -1176,7 +1212,7 @@ class Account(
LocalCache.consume(data, null) LocalCache.consume(data, null)
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id] return LocalCache.getNoteIfExists(signedEvent.id)
} }
fun sendNip95( fun sendNip95(
@@ -1196,7 +1232,7 @@ class Account(
Client.send(signedEvent, relayList = relayList) Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
LocalCache.notes[signedEvent.id]?.let { onReady(it) } LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) }
} }
fun createHeader( fun createHeader(
@@ -1323,9 +1359,10 @@ class Account(
replyingTo: String?, replyingTo: String?,
root: String?, root: String?,
directMentions: Set<HexKey>, directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<Relay>? = null, relayList: List<Relay>? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null, nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1347,6 +1384,7 @@ class Account(
directMentions = directMentions, directMentions = directMentions,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments, nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer, signer = signer,
) { ) {
Client.send(it, relayList = relayList) Client.send(it, relayList = relayList)
@@ -1381,7 +1419,7 @@ class Account(
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null, relayList: List<Relay>? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null, nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1428,7 +1466,7 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null, nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1461,7 +1499,7 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null, nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1495,6 +1533,7 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
sendPrivateMessage( sendPrivateMessage(
message, message,
@@ -1505,6 +1544,7 @@ class Account(
wantsToMarkAsSensitive, wantsToMarkAsSensitive,
zapRaiserAmount, zapRaiserAmount,
geohash, geohash,
nip94attachments,
) )
} }
@@ -1517,6 +1557,7 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1533,6 +1574,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive, markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount, zapRaiserAmount = zapRaiserAmount,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments,
signer = signer, signer = signer,
advertiseNip18 = false, advertiseNip18 = false,
) { ) {
@@ -1551,6 +1593,7 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -1567,6 +1610,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive, markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount, zapRaiserAmount = zapRaiserAmount,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments,
signer = signer, signer = signer,
) { ) {
broadcastPrivately(it) broadcastPrivately(it)
@@ -2031,7 +2075,7 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun changeZapPaymentRequest(newServer: Nip47URI?) { fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
zapPaymentRequest = newServer zapPaymentRequest = newServer
live.invalidateData() live.invalidateData()
saveable.invalidateData() saveable.invalidateData()
@@ -2348,6 +2392,16 @@ class Account(
return lastReadPerRoute[route] ?: 0 return lastReadPerRoute[route] ?: 0
} }
fun hasDonatedInThisVersion(): Boolean {
return hasDonatedInVersion.contains(BuildConfig.VERSION_NAME)
}
fun markDonatedInThisVersion() {
hasDonatedInVersion = hasDonatedInVersion + BuildConfig.VERSION_NAME
saveable.invalidateData()
live.invalidateData()
}
suspend fun registerObservers() = suspend fun registerObservers() =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// saves contact list for the next time. // saves contact list for the next time.
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -58,7 +58,7 @@ class AntiSpamFilter {
// really long, make it ok. // really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls // blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false if (event.content.length < 180 && Nip19Bech32.nip19regex.matcher(event.content).find()) return false
// double list strategy: // double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
@@ -71,7 +71,7 @@ class AntiSpamFilter {
) { ) {
Log.w( Log.w(
"Potential SPAM Message for sharing", "Potential SPAM Message for sharing",
"${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}", "${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, null)}",
) )
Log.w( Log.w(
"Potential SPAM Message", "Potential SPAM Message",
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -32,6 +32,7 @@ fun checkForHashtagWithIcon(
primary: Color, primary: Color,
): HashtagIcon? { ): HashtagIcon? {
return when (tag.lowercase()) { return when (tag.lowercase()) {
"₿itcoin",
"bitcoin", "bitcoin",
"btc", "btc",
"timechain", "timechain",
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -27,12 +27,10 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.encoders.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.AppDefinitionEvent
@@ -50,6 +48,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelHideMessageEvent import com.vitorpamplona.quartz.events.ChannelHideMessageEvent
import com.vitorpamplona.quartz.events.ChannelListEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
@@ -57,18 +56,24 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityListEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent
@@ -80,6 +85,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NNSEvent import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
@@ -94,10 +100,13 @@ import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -109,18 +118,31 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.measureTimedValue
object LocalCache { object LocalCache {
val antiSpam = AntiSpamFilter() val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>(5000) private val users = ConcurrentHashMap<HexKey, User>(5000)
val notes = ConcurrentHashMap<HexKey, Note>(5000) private val notes = ConcurrentHashMap<HexKey, Note>(5000)
val channels = ConcurrentHashMap<HexKey, Channel>() val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100) val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests = val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10) ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
var noteListCache: List<Note> = emptyList()
var userListCache: List<User> = emptyList()
fun updateListCache() {
val (value, elapsed) =
measureTimedValue {
noteListCache = ArrayList(notes.values)
userListCache = ArrayList(users.values)
}
Log.d("LocalCache", "UpdateListCache $elapsed")
}
fun checkGetOrCreateUser(key: String): User? { fun checkGetOrCreateUser(key: String): User? {
// checkNotInMainThread() // checkNotInMainThread()
@@ -357,6 +379,87 @@ object LocalCache {
refreshObservers(note) refreshObservers(note)
} }
fun consume(
event: GitPatchEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: GitIssueEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: GitReplyEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
val replyTo =
event
.tagsWithoutCitations()
.filter { it != event.repository()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
refreshObservers(note)
}
fun consume( fun consume(
event: LongTextNoteEvent, event: LongTextNoteEvent,
relay: Relay?, relay: Relay?,
@@ -392,6 +495,41 @@ object LocalCache {
} }
} }
fun consume(
event: WikiNoteEvent,
relay: Relay?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
refreshObservers(note)
}
}
fun consume( fun consume(
event: PollNoteEvent, event: PollNoteEvent,
relay: Relay? = null, relay: Relay? = null,
@@ -462,6 +600,27 @@ object LocalCache {
consumeBaseReplaceable(event, relay) consumeBaseReplaceable(event, relay)
} }
fun consume(
event: CommunityListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: GitRepositoryEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: ChannelListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume( fun consume(
event: FileServersEvent, event: FileServersEvent,
relay: Relay?, relay: Relay?,
@@ -571,6 +730,28 @@ object LocalCache {
} }
} }
fun consume(
event: OtsEvent,
relay: Relay?,
) {
val version = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (version.event?.id() == event.id()) return
// makes sure the OTS has a valid certificate
if (event.cacheVerify() == null) return // no valid OTS
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.liveSet?.innerOts?.invalidateData()
}
refreshObservers(version)
}
fun consume( fun consume(
event: BadgeDefinitionEvent, event: BadgeDefinitionEvent,
relay: Relay?, relay: Relay?,
@@ -742,7 +923,7 @@ object LocalCache {
event event
.deleteEvents() .deleteEvents()
.mapNotNull { notes[it] } .mapNotNull { getNoteIfExists(it) }
.forEach { deleteNote -> .forEach { deleteNote ->
// must be the same author // must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey) { if (deleteNote.author?.pubkeyHex == event.pubKey) {
@@ -1183,6 +1364,26 @@ object LocalCache {
refreshObservers(note) refreshObservers(note)
} }
fun consume(
event: FhirResourceEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume( fun consume(
event: HighlightEvent, event: HighlightEvent,
relay: Relay?, relay: Relay?,
@@ -1379,11 +1580,14 @@ object LocalCache {
val key = decodePublicKeyAsHexOrNull(username) val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) { if (key != null) {
return listOfNotNull(users[key]) val user = getUserIfExists(key)
if (user != null) {
return listOfNotNull(user)
}
} }
return users.values.filter { return userListCache.filter {
(it.anyNameStartsWith(username)) || (it.anyNameStartsWith(username)) ||
it.pubkeyHex.startsWith(username, true) || it.pubkeyHex.startsWith(username, true) ||
it.pubkeyNpub().startsWith(username, true) it.pubkeyNpub().startsWith(username, true)
@@ -1393,18 +1597,16 @@ object LocalCache {
fun findNotesStartingWith(text: String): List<Note> { fun findNotesStartingWith(text: String): List<Note> {
checkNotInMainThread() checkNotInMainThread()
val key = val key = decodeEventIdAsHexOrNull(text)
try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() if (key != null) {
} catch (e: Exception) { val note = getNoteIfExists(key)
null if (note != null) {
return listOfNotNull(note)
}
} }
if (key != null && (notes[key] ?: addressables[key]) != null) { return noteListCache.filter {
return listOfNotNull(notes[key] ?: addressables[key])
}
return notes.values.filter {
( (
it.event !is GenericRepostEvent && it.event !is GenericRepostEvent &&
it.event !is RepostEvent && it.event !is RepostEvent &&
@@ -1442,13 +1644,7 @@ object LocalCache {
fun findChannelsStartingWith(text: String): List<Channel> { fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread() checkNotInMainThread()
val key = val key = decodeEventIdAsHexOrNull(text)
try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && channels[key] != null) { if (key != null && channels[key] != null) {
return listOfNotNull(channels[key]) return listOfNotNull(channels[key])
} }
@@ -1479,12 +1675,32 @@ object LocalCache {
.toImmutableList() .toImmutableList()
} }
suspend fun findEarliestOtsForNote(note: Note): Long? {
checkNotInMainThread()
var minTime: Long? = null
val time = TimeUtils.now()
noteListCache.forEach { item ->
val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
noteEvent.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime
}
}
}
}
return minTime
}
fun cleanObservers() { fun cleanObservers() {
notes.forEach { it.value.clearLive() } noteListCache.forEach { it.clearLive() }
addressables.forEach { it.value.clearLive() } addressables.forEach { it.value.clearLive() }
users.forEach { it.value.clearLive() } userListCache.forEach { it.clearLive() }
} }
fun pruneOldAndHiddenMessages(account: Account) { fun pruneOldAndHiddenMessages(account: Account) {
@@ -1510,8 +1726,8 @@ object LocalCache {
} }
} }
users.forEach { userPair -> userListCache.forEach { userPair ->
userPair.value.privateChatrooms.values.map { userPair.privateChatrooms.values.map {
val toBeRemoved = it.pruneMessagesToTheLatestOnly() val toBeRemoved = it.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>() val childrenToBeRemoved = mutableListOf<Note>()
@@ -1526,7 +1742,7 @@ object LocalCache {
if (toBeRemoved.size > 1) { if (toBeRemoved.size > 1) {
println( println(
"PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept", "PRUNE: ${toBeRemoved.size} private messages with ${userPair.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
) )
} }
} }
@@ -1535,9 +1751,9 @@ object LocalCache {
fun prunePastVersionsOfReplaceables() { fun prunePastVersionsOfReplaceables() {
val toBeRemoved = val toBeRemoved =
notes noteListCache
.filter { .filter {
val noteEvent = it.value.event val noteEvent = it.event
if (noteEvent is AddressableEvent) { if (noteEvent is AddressableEvent) {
noteEvent.createdAt() < noteEvent.createdAt() <
(addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0) (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
@@ -1545,7 +1761,6 @@ object LocalCache {
false false
} }
} }
.values
val childrenToBeRemoved = mutableListOf<Note>() val childrenToBeRemoved = mutableListOf<Note>()
@@ -1570,24 +1785,23 @@ object LocalCache {
checkNotInMainThread() checkNotInMainThread()
val toBeRemoved = val toBeRemoved =
notes noteListCache
.filter { .filter {
( (
(it.value.event is TextNoteEvent && !it.value.isNewThread()) || (it.event is TextNoteEvent && !it.isNewThread()) ||
it.value.event is ReactionEvent || it.event is ReactionEvent ||
it.value.event is LnZapEvent || it.event is LnZapEvent ||
it.value.event is LnZapRequestEvent || it.event is LnZapRequestEvent ||
it.value.event is ReportEvent || it.event is ReportEvent ||
it.value.event is GenericRepostEvent it.event is GenericRepostEvent
) && ) &&
it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true && it.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
it.value.liveSet?.isInUse() != true && // don't delete if observing. it.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author?.pubkeyHex !in it.author?.pubkeyHex !in
accounts && // don't delete if it is the logged in account accounts && // don't delete if it is the logged in account
it.value.event?.isTaggedUsers(accounts) != it.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user true // don't delete if it's a notification to the logged in user
} }
.values
val childrenToBeRemoved = mutableListOf<Note>() val childrenToBeRemoved = mutableListOf<Note>()
@@ -1653,7 +1867,8 @@ object LocalCache {
fun pruneExpiredEvents() { fun pruneExpiredEvents() {
checkNotInMainThread() checkNotInMainThread()
val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values val now = TimeUtils.now()
val toBeRemoved = noteListCache.filter { it.event?.isExpirationBefore(now) == true }
val childrenToBeRemoved = mutableListOf<Note>() val childrenToBeRemoved = mutableListOf<Note>()
@@ -1679,7 +1894,7 @@ object LocalCache {
?.hiddenUsers ?.hiddenUsers
?.map { userHex -> ?.map { userHex ->
( (
notes.values.filter { it.event?.pubKey() == userHex } + noteListCache.filter { it.event?.pubKey() == userHex } +
addressables.values.filter { it.event?.pubKey() == userHex } addressables.values.filter { it.event?.pubKey() == userHex }
) )
.toSet() .toSet()
@@ -1701,7 +1916,7 @@ object LocalCache {
checkNotInMainThread() checkNotInMainThread()
var removingContactList = 0 var removingContactList = 0
users.values.forEach { userListCache.forEach {
if ( if (
it.pubkeyHex !in loggedIn && it.pubkeyHex !in loggedIn &&
(it.liveSet == null || it.liveSet?.isInUse() == false) && (it.liveSet == null || it.liveSet?.isInUse() == false) &&
@@ -1738,6 +1953,7 @@ object LocalCache {
try { try {
event.checkSignature() event.checkSignature()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson())
} }
false false
@@ -1768,6 +1984,7 @@ object LocalCache {
is CalendarTimeSlotEvent -> consume(event, relay) is CalendarTimeSlotEvent -> consume(event, relay)
is CalendarRSVPEvent -> consume(event, relay) is CalendarRSVPEvent -> consume(event, relay)
is ChannelCreateEvent -> consume(event) is ChannelCreateEvent -> consume(event)
is ChannelListEvent -> consume(event, relay)
is ChannelHideMessageEvent -> consume(event) is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay) is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event) is ChannelMetadataEvent -> consume(event)
@@ -1775,6 +1992,7 @@ object LocalCache {
is ChatMessageEvent -> consume(event, relay) is ChatMessageEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay) is CommunityDefinitionEvent -> consume(event, relay)
is CommunityListEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> { is CommunityPostApprovalEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) } event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event) consume(event)
@@ -1784,11 +2002,16 @@ object LocalCache {
is EmojiPackEvent -> consume(event, relay) is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay) is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay) is SealedGossipEvent -> consume(event, relay)
is FhirResourceEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay) is FileHeaderEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay) is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay) is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay) is FileStorageHeaderEvent -> consume(event, relay)
is GiftWrapEvent -> consume(event, relay) is GiftWrapEvent -> consume(event, relay)
is GitIssueEvent -> consume(event, relay)
is GitReplyEvent -> consume(event, relay)
is GitPatchEvent -> consume(event, relay)
is GitRepositoryEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay) is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay) is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay) is LiveActivitiesChatMessageEvent -> consume(event, relay)
@@ -1806,6 +2029,7 @@ object LocalCache {
is MetadataEvent -> consume(event) is MetadataEvent -> consume(event)
is MuteListEvent -> consume(event, relay) is MuteListEvent -> consume(event, relay)
is NNSEvent -> comsume(event, relay) is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay) is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay) is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay) is PeopleListEvent -> consume(event, relay)
@@ -1826,11 +2050,13 @@ object LocalCache {
is TextNoteEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay)
is VideoHorizontalEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay)
is VideoVerticalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay)
is WikiNoteEvent -> consume(event, relay)
else -> { else -> {
Log.w("Event Not Supported", event.toJson()) Log.w("Event Not Supported", event.toJson())
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace() e.printStackTrace()
} }
} }
@@ -1845,6 +2071,10 @@ class LocalCacheLiveData {
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO) private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) { fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } bundler.invalidateList(newNote) {
bundledNewNotes ->
_newEventBundles.emit(bundledNewNotes)
LocalCache.updateListCache()
}
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNote import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
@@ -63,12 +64,16 @@ import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
@Stable @Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) { class AddressableNote(val address: ATag) : Note(address.toTag()) {
@@ -137,17 +142,17 @@ open class Note(val idHex: String) {
return if (myEvent is WrappedEvent) { return if (myEvent is WrappedEvent) {
val host = myEvent.host val host = myEvent.host
if (host != null) { if (host != null) {
Nip19.createNEvent( Nip19Bech32.createNEvent(
host.id, host.id,
host.pubKey, host.pubKey,
host.kind(), host.kind(),
relays.firstOrNull()?.url, relays.firstOrNull()?.url,
) )
} else { } else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
} }
} else { } else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
} }
} }
@@ -520,7 +525,7 @@ open class Note(val idHex: String) {
} }
} }
private fun recursiveIsZappedByCalculation( private suspend fun isZappedByCalculation(
option: Int?, option: Int?,
user: User, user: User,
account: Account, account: Account,
@@ -531,48 +536,65 @@ open class Note(val idHex: String) {
return return
} }
val next = remainingZapEvents.first() remainingZapEvents.forEach { next ->
val zapRequest = next.first.event as LnZapRequestEvent
val zapEvent = next.second?.event as? LnZapEvent
if (next.first.author?.pubkeyHex == user.pubkeyHex) { if (!zapRequest.isPrivateZap()) {
// public events
if (zapRequest.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor() onWasZappedByAuthor()
return
}
} else { } else {
account.decryptZapContentAuthor(next.first) { // private events
if (
it.pubKey == user.pubkeyHex && // if has already decrypted
(option == null || option == (it as? LnZapEvent)?.zappedPollOption()) val privateZap = zapRequest.cachedPrivateZap()
) { if (privateZap != null) {
if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor() onWasZappedByAuthor()
return
}
} else { } else {
recursiveIsZappedByCalculation( if (account.isWriteable()) {
option, val result =
user, withTimeoutOrNull(1000) {
account, suspendCancellableCoroutine { continuation ->
remainingZapEvents.minus(next), zapRequest.decryptPrivateZap(account.signer) {
onWasZappedByAuthor, continuation.resume(it)
) }
}
}
if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
}
}
} }
} }
} }
} }
fun isZappedBy( suspend fun isZappedBy(
user: User, user: User,
account: Account, account: Account,
onWasZappedByAuthor: () -> Unit, onWasZappedByAuthor: () -> Unit,
) { ) {
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
if (account.userProfile() == user) { if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
} }
} }
fun isZappedBy( suspend fun isZappedBy(
option: Int?, option: Int?,
user: User, user: User,
account: Account, account: Account,
onWasZappedByAuthor: () -> Unit, onWasZappedByAuthor: () -> Unit,
) { ) {
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
} }
fun getReactionBy(user: User): String? { fun getReactionBy(user: User): String? {
@@ -614,7 +636,7 @@ open class Note(val idHex: String) {
// Regular Zap Receipts // Regular Zap Receipts
zaps.forEach { zaps.forEach {
val noteEvent = it?.value?.event val noteEvent = it.value?.event
if (noteEvent is LnZapEvent) { if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
} }
@@ -644,6 +666,7 @@ open class Note(val idHex: String) {
try { try {
LnInvoiceUtil.getAmountInSats(invoice) LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) { } catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
null null
} }
@@ -694,6 +717,7 @@ open class Note(val idHex: String) {
try { try {
BigDecimal(it.event?.content()) BigDecimal(it.event?.content())
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
null null
// do nothing if it can't convert to bigdecimal // do nothing if it can't convert to bigdecimal
} }
@@ -709,6 +733,7 @@ open class Note(val idHex: String) {
try { try {
BigDecimal(it.event?.content()) BigDecimal(it.event?.content())
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
null null
// do nothing if it can't convert to bigdecimal // do nothing if it can't convert to bigdecimal
} }
@@ -922,6 +947,7 @@ class NoteLiveSet(u: Note) {
val innerReports = NoteBundledRefresherLiveData(u) val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u) val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u) val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val metadata = innerMetadata.map { it } val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it } val reactions = innerReactions.map { it }
@@ -986,6 +1012,7 @@ class NoteLiveSet(u: Note) {
innerReports.destroy() innerReports.destroy()
innerRelays.destroy() innerRelays.destroy()
innerZaps.destroy() innerZaps.destroy()
innerOts.destroy()
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -139,10 +139,10 @@ class User(val pubkeyHex: String) {
// Update Followers of the past user list // Update Followers of the past user list
// Update Followers of the new contact list // Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
} }
(latestContactList)?.unverifiedFollowKeySet()?.forEach { (latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
} }
liveSet?.innerRelays?.invalidateData() liveSet?.innerRelays?.invalidateData()
@@ -363,7 +363,7 @@ class User(val pubkeyHex: String) {
} }
suspend fun transientFollowerCount(): Int { suspend fun transientFollowerCount(): Int {
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
} }
fun cachedFollowingKeySet(): Set<HexKey> { fun cachedFollowingKeySet(): Set<HexKey> {
@@ -387,7 +387,7 @@ class User(val pubkeyHex: String) {
} }
suspend fun cachedFollowerCount(): Int { suspend fun cachedFollowerCount(): Int {
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
} }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean { fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -20,40 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache import android.util.LruCache
import android.util.Patterns import com.vitorpamplona.amethyst.commons.RichTextParser
import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import java.util.regex.Pattern
@Immutable
data class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>,
)
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser { object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200) val richTextCache = LruCache<String, RichTextViewerState>(200)
@@ -71,288 +41,3 @@ object CachedRichTextParser {
} }
} }
} }
// Group 1 = url, group 4 additional chars
// 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 HTTPRegex =
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"],
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"],
)
} else {
null
}
}
fun parseText(
content: 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 {
null
}
}
}
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
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>()
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
}
val newSegments =
if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
}
return paragraphSegments
}
fun isNumber(word: String): Boolean {
return numberPattern.matcher(word).matches()
}
fun isDate(word: String): Boolean {
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
}
private fun isArabic(text: String): Boolean {
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 {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
return if (word.isEmpty()) {
RegularTextSegment(word)
} else if (images.contains(word)) {
ImageSegment(word)
} else if (urls.contains(word)) {
LinkSegment(word)
} else if (emojis.any { word.contains(it.key) }) {
EmojiSegment(word)
} else if (word.startsWith("lnbc", true)) {
InvoiceSegment(word)
} else if (word.startsWith("lnurl", true)) {
WithdrawSegment(word)
} else if (word.startsWith("cashuA", true)) {
CashuSegment(word)
} else if (emailMatcher.matches()) {
EmailSegment(word)
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
PhoneSegment(word)
} else if (startsWithNIP19Scheme(word)) {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} 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)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
}
} else {
RegularTextSegment(word)
}
}
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
try {
if (matcher.find()) {
val index = matcher.group(1)?.toInt()
val suffix = matcher.group(2)
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
}
}
}
}
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
val hashtagMatcher = hashTagsPattern.matcher(word)
try {
if (hashtagMatcher.find()) {
val hashtag = hashtagMatcher.group(1)
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
}
}
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
}
@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
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)
@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)
@Immutable class RegularTextSegment(segment: String) : Segment(segment)
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -32,6 +32,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64 import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
@Immutable @Immutable
data class CashuToken( data class CashuToken(
@@ -59,6 +60,7 @@ class CashuProcessor {
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token") return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
} }
} }
@@ -119,7 +121,7 @@ class CashuProcessor {
checkNotInMainThread() checkNotInMainThread()
try { try {
val client = HttpClient.getHttpClient() val client = HttpClientManager.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory val factory = Event.mapper.nodeFactory
@@ -154,6 +156,7 @@ class CashuProcessor {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
onError( onError(
context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
@@ -170,7 +173,7 @@ class CashuProcessor {
context: Context, context: Context,
) { ) {
try { try {
val client = HttpClient.getHttpClient() val client = HttpClientManager.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint val url = token.mint + "/melt" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory val factory = Event.mapper.nodeFactory
@@ -211,6 +214,7 @@ class CashuProcessor {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
onError( onError(
context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash import io.trbl.blurhash.BlurHash
import kotlinx.coroutines.CancellationException
import java.io.IOException import java.io.IOException
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -59,6 +60,7 @@ class FileHeader(
onError(null) onError(null)
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError(e.message) onError(e.message)
} }
@@ -174,6 +176,7 @@ class FileHeader(
onReady(FileHeader(mimeType, hash, size, dim, blurHash)) onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError(e.message) onError(e.message)
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -32,14 +32,13 @@ import java.net.Proxy
import java.time.Duration import java.time.Duration
import kotlin.properties.Delegates import kotlin.properties.Delegates
object HttpClient { object HttpClientManager {
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
var proxyChangeListeners = ArrayList<() -> Unit>() var proxyChangeListeners = ArrayList<() -> Unit>()
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
var defaultHttpClient: OkHttpClient? = null
// fires off every time value of the property changes // fires off every time value of the property changes
private var internalProxy: Proxy? by private var internalProxy: Proxy? by
@@ -49,26 +48,34 @@ object HttpClient {
} }
} }
fun start(proxy: Proxy?) { fun setDefaultProxy(proxy: Proxy?) {
if (internalProxy != proxy) { if (internalProxy != proxy) {
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
this.internalProxy = proxy this.internalProxy = proxy
this.defaultHttpClient = getHttpClient()
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
} }
} }
fun changeTimeouts(timeout: Duration) { fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout") Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) { if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultTimeout = timeout this.defaultTimeout = timeout
this.defaultHttpClient = getHttpClient()
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
} }
} }
fun getHttpClient(timeout: Duration): OkHttpClient { private fun buildHttpClient(
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
val duration = Duration.ofSeconds(seconds) val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder() return OkHttpClient.Builder()
.proxy(internalProxy) .proxy(proxy)
.readTimeout(duration) .readTimeout(duration)
.connectTimeout(duration) .connectTimeout(duration)
.writeTimeout(duration) .writeTimeout(duration)
@@ -91,24 +98,13 @@ object HttpClient {
} }
} }
fun getHttpClientForRelays(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
}
fun getHttpClient(): OkHttpClient { fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) { if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout) this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
} }
return defaultHttpClient!! return defaultHttpClient!!
} }
fun getProxy(): Proxy? {
return internalProxy
}
fun initProxy( fun initProxy(
useProxy: Boolean, useProxy: Boolean,
hostname: String, hostname: String,
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -29,6 +29,7 @@ import android.location.LocationManager
import android.os.HandlerThread import android.os.HandlerThread
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) { class LocationUtil(context: Context) {
@@ -105,6 +106,7 @@ class ReverseGeoLocationUtil {
.joinToString(", ") .joinToString(", ")
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace() e.printStackTrace()
return null return null
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Call import okhttp3.Call
@@ -64,7 +65,7 @@ class Nip05NostrAddressVerifier() {
.url(url) .url(url)
.build() .build()
HttpClient.getHttpClient() HttpClientManager.getHttpClient()
.newCall(request) .newCall(request)
.enqueue( .enqueue(
object : Callback { object : Callback {
@@ -96,7 +97,8 @@ class Nip05NostrAddressVerifier() {
} }
}, },
) )
} catch (e: java.lang.Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not resolve '$url': ${e.message}") onError("Could not resolve '$url': ${e.message}")
} }
} }
@@ -122,7 +124,8 @@ class Nip05NostrAddressVerifier() {
val nip05url = val nip05url =
try { try {
mapper.readTree(it.lowercase()) mapper.readTree(it.lowercase())
} catch (t: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null null
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.service
import android.util.Log import android.util.Log
import android.util.LruCache import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
@@ -30,29 +32,64 @@ import okhttp3.Response
import java.io.IOException import java.io.IOException
object Nip11CachedRetriever { object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100) open class RetrieveResult(val time: Long)
class RetrieveResultError(val error: Nip11Retriever.ErrorCode, val msg: String? = null) : RetrieveResult(TimeUtils.now())
class RetrieveResultSuccess(val data: Nip11RelayInformation) : RetrieveResult(TimeUtils.now())
val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
val retriever = Nip11Retriever() val retriever = Nip11Retriever()
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
if (result is RetrieveResultSuccess) return result.data
return null
}
suspend fun loadRelayInfo( suspend fun loadRelayInfo(
dirtyUrl: String, dirtyUrl: String,
onInfo: (RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) { ) {
val url = retriever.cleanUrl(dirtyUrl) val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url) val doc = relayInformationDocumentCache.get(url)
if (doc != null) { if (doc != null) {
onInfo(doc) if (doc is RetrieveResultSuccess) {
onInfo(doc.data)
} else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
} else { } else {
Nip11Retriever() Nip11Retriever()
.loadRelayInfo( .loadRelayInfo(
url, url = url,
dirtyUrl, dirtyUrl = dirtyUrl,
onInfo = { onInfo = {
relayInformationDocumentCache.put(url, it) relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it) onInfo(it)
}, },
onError, onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
) )
} }
} }
@@ -77,14 +114,15 @@ class Nip11Retriever {
suspend fun loadRelayInfo( suspend fun loadRelayInfo(
url: String, url: String,
dirtyUrl: String, dirtyUrl: String,
onInfo: (RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit, onError: (String, ErrorCode, String?) -> Unit,
) { ) {
checkNotInMainThread()
try { try {
val request: Request = val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build() Request.Builder().header("Accept", "application/nostr+json").url(url).build()
HttpClient.getHttpClient() HttpClientManager.getHttpClient()
.newCall(request) .newCall(request)
.enqueue( .enqueue(
object : Callback { object : Callback {
@@ -97,11 +135,12 @@ class Nip11Retriever {
val body = it.body.string() val body = it.body.string()
try { try {
if (it.isSuccessful) { if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body)) onInfo(Nip11RelayInformation.fromJson(body))
} else { } else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e( Log.e(
"RelayInfoFail", "RelayInfoFail",
"Resulting Message from Relay $dirtyUrl in not parseable: $body", "Resulting Message from Relay $dirtyUrl in not parseable: $body",
@@ -122,6 +161,7 @@ class Nip11Retriever {
}, },
) )
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
} }
@@ -1,53 +0,0 @@
/**
* 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
// Rename to the corect nip number when ready.
object Nip47WalletConnectParser {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex =
try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Nip47URI(pubkeyHex, relay, secret)
}
}
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -24,6 +24,7 @@ import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.coroutines.CancellationException
import okhttp3.Request import okhttp3.Request
object Nip96MediaServers { object Nip96MediaServers {
@@ -87,7 +88,7 @@ class Nip96Retriever {
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build() .build()
HttpClient.getHttpClient().newCall(request).execute().use { response -> HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
checkNotInMainThread() checkNotInMainThread()
response.use { response.use {
val body = it.body.string() val body = it.body.string()
@@ -100,6 +101,7 @@ class Nip96Retriever {
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e throw e
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -128,7 +128,7 @@ class Nip96Uploader(val account: Account?) {
val extension = val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient() val client = HttpClientManager.getHttpClient()
val requestBody: RequestBody val requestBody: RequestBody
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
@@ -194,7 +194,7 @@ class Nip96Uploader(val account: Account?) {
val extension = val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient() val client = HttpClientManager.getHttpClient()
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
@@ -227,7 +227,7 @@ class Nip96Uploader(val account: Account?) {
server: Nip96Retriever.ServerInfo, server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
): PartialEvent { ): PartialEvent {
val client = HttpClient.getHttpClient() val client = HttpClientManager.getHttpClient()
var currentResult = result var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -248,19 +248,11 @@ abstract class NostrDataSource(val debugName: String) {
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id) Client.close(updatedSubscription.id)
if (active) { if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
} }
} else { } else {
// hasn't changed, does nothing. // hasn't changed, does nothing.
if (active) { if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilterOnlyIfDisconnected( Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id, updatedSubscription.id,
updatedSubscriptionNewFilters, updatedSubscriptionNewFilters,
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -24,9 +24,12 @@ import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexValidator import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -46,6 +49,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null private var searchString: String? = null
@@ -65,32 +69,68 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
null null
} }
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex when (val parsed = Nip19Bech32.uriToRoute(mySearchString)?.entity) {
is Nip19Bech32.NSec -> KeyPair(privKey = parsed.hex.bechToBytes()).pubKey.toHexKey()
is Nip19Bech32.NPub -> parsed.hex
is Nip19Bech32.NProfile -> parsed.hex
is Nip19Bech32.Note -> parsed.hex
is Nip19Bech32.NEvent -> parsed.hex
is Nip19Bech32.NEmbed -> parsed.event.id
is Nip19Bech32.NRelay -> null
is Nip19Bech32.NAddress -> parsed.atag
else -> isAStraightHex
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
null null
} }
// downloads all the reactions to a given event. val directReferenceFilters =
return listOfNotNull(
hexToWatch?.let { hexToWatch?.let {
if (it.contains(":")) {
// naddr
listOfNotNull(
ATag.parse(it, null)?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
limit = 5,
),
)
},
)
} else {
// event ids
listOf(
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
ids = listOfNotNull(hexToWatch), ids = listOfNotNull(hexToWatch),
), ),
) ),
}, // authors
hexToWatch?.let {
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch), authors = listOfNotNull(hexToWatch),
// just to be sure
limit = 5,
),
), ),
) )
}, }
} ?: emptyList()
// downloads all the reactions to a given event.
return directReferenceFilters +
listOfNotNull(
TypedFilter( TypedFilter(
types = setOf(FeedType.SEARCH), types = setOf(FeedType.SEARCH),
filter = filter =
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
@@ -120,6 +121,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
} }
return groupByEOSEPresence(eventsToWatch).map { return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
@@ -133,14 +135,37 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReportEvent.KIND, ReportEvent.KIND,
LnZapEvent.KIND, LnZapEvent.KIND,
PollNoteEvent.KIND, PollNoteEvent.KIND,
OtsEvent.KIND,
), ),
tags = mapOf("e" to it.map { it.idHex }), tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it), since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event. // Max amount of "replies" to download on a specific event.
limit = 1000, limit = 1000,
), ),
),
) )
}.flatten()
} }
private fun createQuotesFilter(): List<TypedFilter>? {
if (eventsToWatch.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
)
}.flatten()
} }
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? { fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
@@ -205,9 +230,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val missing = createLoadEventsIfNotLoadedFilter() val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter() val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter() val addressReactions = createReactionsToWatchInAddressFilter()
val quotes = createQuotesFilter()
singleEventChannel.typedFilters = singleEventChannel.typedFilters =
listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null }
} }
fun add(eventId: Note) { fun add(eventId: Note) {
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -25,6 +25,7 @@ import android.util.LruCache
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request 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)
@@ -59,13 +60,14 @@ object OnlineChecker {
.build() .build()
val result = val result =
HttpClient.getHttpClient().newCall(request).execute().use { HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread() checkNotInMainThread()
it.isSuccessful it.isSuccessful
} }
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result result
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e) Log.e("LiveActivities", "Failed to check streaming url $url", e)
false false
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
object PackageUtils { object PackageUtils {
private fun isPackageInstalled( private fun isPackageInstalled(
@@ -36,7 +38,13 @@ object PackageUtils {
return isPackageInstalled(context, "org.torproject.android") return isPackageInstalled(context, "org.torproject.android")
} }
fun isAmberInstalled(context: Context): Boolean { fun isExternalSignerInstalled(context: Context): Boolean {
return isPackageInstalled(context, "com.greenart7c3.nostrsigner") val intent =
Intent().apply {
action = Intent.ACTION_VIEW
data = Uri.parse("nostrsigner:")
}
val infos = context.packageManager.queryIntentActivities(intent, 0)
return infos.size > 0
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -24,7 +24,7 @@ import android.content.Context
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R 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.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06 import com.vitorpamplona.quartz.encoders.Lud06
@@ -33,9 +33,10 @@ import okhttp3.Request
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.net.URLEncoder import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver() { class LightningAddressResolver() {
val client = HttpClient.getHttpClient() val client = HttpClientManager.getHttpClient()
fun assembleUrl(lnaddress: String): String? { fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@") val parts = lnaddress.split("@")
@@ -96,14 +97,16 @@ class LightningAddressResolver() {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace() e.printStackTrace()
onError( onError(
context.getString(R.string.error_unable_to_fetch_invoice), context.getString(R.string.error_unable_to_fetch_invoice),
context.getString( context.getString(
R.string R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct, .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url, url,
lnaddress, lnaddress,
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
), ),
) )
} }
@@ -183,6 +186,7 @@ class LightningAddressResolver() {
try { try {
mapper.readTree(lnAddressJson) mapper.readTree(lnAddressJson)
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t
onError( onError(
context.getString(R.string.error_unable_to_fetch_invoice), context.getString(R.string.error_unable_to_fetch_invoice),
context.getString( context.getString(
@@ -218,6 +222,7 @@ class LightningAddressResolver() {
try { try {
mapper.readTree(it) mapper.readTree(it)
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t
onError( onError(
context.getString(R.string.error_unable_to_fetch_invoice), context.getString(R.string.error_unable_to_fetch_invoice),
context.getString( context.getString(
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -112,7 +112,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event.pubKey != acc.userProfile().pubkeyHex event.pubKey != acc.userProfile().pubkeyHex
) { // from the user ) { // from the user
val chatNote = LocalCache.notes[event.id] ?: return val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet() val followingKeySet = acc.followingKeySet()
@@ -145,7 +145,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event: PrivateDmEvent, event: PrivateDmEvent,
acc: Account, acc: Account,
) { ) {
val note = LocalCache.notes[event.id] ?: return val note = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast // old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
@@ -184,7 +184,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event: LnZapEvent, event: LnZapEvent,
acc: Account, acc: Account,
) { ) {
val noteZapEvent = LocalCache.notes[event.id] ?: return val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast // old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -25,8 +25,9 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account 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 com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -106,10 +107,11 @@ class RegisterAccounts(
.post(body) .post(body)
.build() .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) { } catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
val tag = val tag =
if (BuildConfig.FLAVOR == "play") { if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService" "FirebaseMsgService"
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ots
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.ots.BitcoinExplorer
import com.vitorpamplona.quartz.ots.BlockHeader
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import okhttp3.Request
class OkHttpBlockstreamExplorer : BitcoinExplorer {
/**
* Retrieve the block information from the block hash.
*
* @param hash Hash of the block.
* @return the blockheader of the hash
* @throws Exception desc
*/
override fun block(hash: String): BlockHeader {
val client = HttpClientManager.getHttpClient()
val url = "$BLOCKSTREAM_API_URL/block/$hash"
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.url(url)
.get()
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val jsonObject = jacksonObjectMapper().readTree(it.body.string())
val blockHeader = BlockHeader()
blockHeader.merkleroot = jsonObject["merkle_root"].asText()
blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
blockHeader.blockHash = hash
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash")
return blockHeader
} else {
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
}
}
}
/**
* Retrieve the block hash from the block height.
*
* @param height Height of the block.
* @return the hash of the block at height height
* @throws Exception desc
*/
@Throws(Exception::class)
override fun blockHash(height: Int): String {
val client = HttpClientManager.getHttpClient()
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val blockHash = it.body.string()
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
return blockHash
} else {
throw UrlException(it.message)
}
}
}
companion object {
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
}
}
@@ -0,0 +1,133 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.ots.Timestamp
import com.vitorpamplona.quartz.ots.exceptions.CommitmentNotFoundException
import com.vitorpamplona.quartz.ots.exceptions.DeserializationException
import com.vitorpamplona.quartz.ots.exceptions.ExceededSizeException
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import com.vitorpamplona.quartz.ots.http.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Class representing remote calendar server interface.
*/
class OkHttpCalendar(val url: String) : ICalendar {
/**
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
*
* @param digest The digest hash to send.
* @return the Timestamp received from the calendar.
* @throws ExceededSizeException if response is too big.
* @throws UrlException if url is not reachable.
* @throws DeserializationException if the data is corrupt
*/
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
override fun submit(digest: ByteArray): Timestamp {
try {
val client = HttpClientManager.getHttpClient()
val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val ctx = StreamDeserializationContext(it.body.bytes())
return Timestamp.deserialize(ctx, digest)
} else {
throw UrlException("Failed to open $url")
}
}
} catch (e: ExceededSizeException) {
throw e
} catch (e: DeserializationException) {
throw e
} catch (e: Exception) {
throw UrlException(e.message)
}
}
/**
* Get a timestamp for a given commitment.
*
* @param commitment The digest hash to send.
* @return the Timestamp from the calendar server (with blockchain information if already written).
* @throws ExceededSizeException if response is too big.
* @throws UrlException if url is not reachable.
* @throws CommitmentNotFoundException if commit is not found.
* @throws DeserializationException if the data is corrupt
*/
@Throws(
DeserializationException::class,
ExceededSizeException::class,
CommitmentNotFoundException::class,
UrlException::class,
)
override fun getTimestamp(commitment: ByteArray): Timestamp {
try {
val client = HttpClientManager.getHttpClient()
val url = url + "/timestamp/" + Hex.encode(commitment)
val request =
okhttp3.Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
.url(url)
.get()
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val ctx = StreamDeserializationContext(it.body.bytes())
return Timestamp.deserialize(ctx, commitment)
} else {
throw CommitmentNotFoundException("Calendar response a status code != 200: " + it.code)
}
}
} catch (e: DeserializationException) {
throw e
} catch (e: ExceededSizeException) {
throw e
} catch (e: CommitmentNotFoundException) {
throw e
} catch (e: Exception) {
throw UrlException(e.message)
}
}
}
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.ots.Timestamp
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Optional
import java.util.concurrent.BlockingQueue
/**
* For making async calls to a calendar server
*/
class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: ByteArray) : ICalendarAsyncSubmit {
private var queue: BlockingQueue<Optional<Timestamp>>? = null
fun setQueue(queue: BlockingQueue<Optional<Timestamp>>?) {
this.queue = queue
}
@Throws(Exception::class)
override fun call(): Optional<Timestamp> {
val client = HttpClientManager.getHttpClient()
val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val ctx = StreamDeserializationContext(it.body.bytes())
val timestamp = Timestamp.deserialize(ctx, digest)
val of = Optional.of(timestamp)
queue!!.add(of)
return of
} else {
queue!!.add(Optional.empty())
return Optional.empty()
}
}
}
}
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -18,8 +18,21 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.ots.CalendarBuilder
import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?) class OkHttpCalendarBuilder : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar {
return OkHttpCalendar(url)
}
override fun newAsyncCalendar(
url: String,
digest: ByteArray,
): ICalendarAsyncSubmit {
return OkHttpCalendarAsyncSubmit(url, digest)
}
}
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -28,6 +28,7 @@ import android.util.LruCache
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.CancellationException
object PlaybackClientController { object PlaybackClientController {
val cache = LruCache<Int, SessionToken>(1) val cache = LruCache<Int, SessionToken>(1)
@@ -62,12 +63,14 @@ object PlaybackClientController {
try { try {
onReady(controllerFuture.get()) onReady(controllerFuture.get())
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
} }
}, },
MoreExecutors.directExecutor(), MoreExecutors.directExecutor(),
) )
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -31,7 +31,7 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClientManager
@UnstableApi // Extend MediaSessionService @UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
@@ -42,12 +42,12 @@ class PlaybackService : MediaSessionService() {
private var managerLocal: MultiPlayerPlaybackManager? = null private var managerLocal: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory { fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
} }
fun newProgressiveDataSource(): MediaSource.Factory { fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory( return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()), (applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
) )
} }
@@ -90,7 +90,7 @@ class PlaybackService : MediaSessionService() {
Log.d("Lifetime Event", "PlaybackService.onCreate") Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes. // 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() { private fun onProxyUpdated() {
@@ -114,7 +114,7 @@ class PlaybackService : MediaSessionService() {
override fun onDestroy() { override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy") Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers() managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers() managerLocal?.releaseAppPlayers()
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.previews package com.vitorpamplona.amethyst.service.previews
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -29,6 +30,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
try { try {
fetch(timeOut) fetch(timeOut)
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t) callback?.onFailed(t)
} }
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -20,7 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.service.previews 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType import okhttp3.MediaType
@@ -90,7 +91,8 @@ suspend fun getDocument(
): UrlInfoItem = ): UrlInfoItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build() val request: Request = Request.Builder().url(url).get().build()
HttpClient.getHttpClient().newCall(request).execute().use { HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
if (it.isSuccessful) { if (it.isSuccessful) {
val mimeType = val mimeType =
it.headers.get("Content-Type")?.toMediaType() it.headers.get("Content-Type")?.toMediaType()
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -108,7 +108,7 @@ object Client : RelayPool.Listener {
checkNotInMainThread() checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters) subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) RelayPool.connectAndSendFiltersIfDisconnected()
} }
fun send( fun send(
@@ -292,7 +292,7 @@ object Client : RelayPool.Listener {
open fun onRelayStateChange( open fun onRelayStateChange(
type: Relay.StateType, type: Relay.StateType,
relay: Relay, relay: Relay,
channel: String?, subscriptionId: String?,
) = Unit ) = Unit
/** When an relay saves or rejects a new event. */ /** When an relay saves or rejects a new event. */
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.bytesUsedInMemory import com.vitorpamplona.quartz.events.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
@@ -62,7 +63,7 @@ class Relay(
const val RECONNECTING_IN_SECONDS = 60 * 3 const val RECONNECTING_IN_SECONDS = 60 * 3
} }
private val httpClient = HttpClient.getHttpClientForRelays() private val httpClient = HttpClientManager.getHttpClient()
private var listeners = setOf<Listener>() private var listeners = setOf<Listener>()
private var socket: WebSocket? = null private var socket: WebSocket? = null
@@ -76,7 +77,7 @@ class Relay(
var errorCounter = 0 var errorCounter = 0
var pingInMs: Long? = null var pingInMs: Long? = null
var closingTimeInSeconds = 0L var lastConnectTentative: Long = 0L
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>() var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
@@ -106,7 +107,7 @@ class Relay(
private var connectingBlock = AtomicBoolean() private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) { fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url") Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
// BRB is crashing OkHttp Deflater object :( // BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return if (url.contains("brb.io")) return
@@ -119,6 +120,8 @@ class Relay(
if (socket != null) return if (socket != null) return
lastConnectTentative = TimeUtils.now()
try { try {
val request = val request =
Request.Builder() Request.Builder()
@@ -128,6 +131,8 @@ class Relay(
socket = httpClient.newWebSocket(request, RelayListener(onConnected)) socket = httpClient.newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
errorCounter++ errorCounter++
markConnectionAsClosed() markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url") Log.e("Relay", "Relay Invalid $url")
@@ -167,8 +172,9 @@ class Relay(
try { try {
processNewRelayMessage(text) processNewRelayMessage(text)
} catch (t: Throwable) { } catch (e: Throwable) {
t.printStackTrace() if (e is CancellationException) throw e
e.printStackTrace()
text.chunked(2000) { chunked -> text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
} }
@@ -247,7 +253,6 @@ class Relay(
this.isReady = false this.isReady = false
this.usingCompression = false this.usingCompression = false
this.resetEOSEStatuses() this.resetEOSEStatuses()
this.closingTimeInSeconds = TimeUtils.now()
} }
fun processNewRelayMessage(newMessage: String) { fun processNewRelayMessage(newMessage: String) {
@@ -327,7 +332,7 @@ class Relay(
Log.d("Relay", "Relay.disconnect $url") Log.d("Relay", "Relay.disconnect $url")
checkNotInMainThread() checkNotInMainThread()
closingTimeInSeconds = TimeUtils.now() lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested.
socket?.cancel() socket?.cancel()
socket = null socket = null
isReady = false isReady = false
@@ -369,7 +374,7 @@ class Relay(
} }
} else { } else {
// waits 60 seconds to reconnect after disconnected. // waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful. // sends all filters after connection is successful.
connect() connect()
} }
@@ -404,12 +409,12 @@ class Relay(
return buffer.toString() return buffer.toString()
} }
fun sendFilterOnlyIfDisconnected(subscriptionId: String) { fun connectAndSendFiltersIfDisconnected() {
checkNotInMainThread() checkNotInMainThread()
if (socket == null) { if (socket == null) {
// waits 60 seconds to reconnect after disconnected. // waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) {
// println("sendfilter Only if Disconnected ${url} ") // println("sendfilter Only if Disconnected ${url} ")
connect() connect()
} }
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -81,8 +81,8 @@ object RelayPool : Relay.Listener {
relays.forEach { it.sendFilter(subscriptionId) } relays.forEach { it.sendFilter(subscriptionId) }
} }
fun sendFilterOnlyIfDisconnected(subscriptionId: String) { fun connectAndSendFiltersIfDisconnected() {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) } relays.forEach { it.connectAndSendFiltersIfDisconnected() }
} }
fun sendToSelectedRelays( fun sendToSelectedRelays(
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2023 Vitor Pamplona * Copyright (c) 2024 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in * this software and associated documentation files (the "Software"), to deal in

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