diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 18105c04d..65829028f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -227,4 +227,9 @@ jobs: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab - asset_content_type: application/zip \ No newline at end of file + asset_content_type: application/zip + + - name: Drafts a description for the release + uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index f8467b458..8d81632f8 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 429514296..51a530e1d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,42 @@ -# Amethyst: Nostr client for Android +
+ + + Amethyst Logo + + +# Amethyst + +## Nostr Client for Android - +Join the social network you control. -Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting. +[![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 [Get it on Obtaininum](https://github.com/ImranR98/Obtainium) +height="70">](https://github.com/ImranR98/Obtainium) [Get it on GitHub](https://github.com/vitorpamplona/amethyst/releases) +height="70">](https://github.com/vitorpamplona/amethyst/releases) [Get it on F-Droid](https://f-droid.org/packages/com.vitorpamplona.amethyst/) + height="70">](https://f-droid.org/packages/com.vitorpamplona.amethyst/) [Get it on Google Play](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 +## Supported Features + + - [x] Events / Relay Subscriptions (NIP-01) - [x] Follow List (NIP-02) @@ -27,7 +47,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [ ] WebBrowser Signer (NIP-07, Not applicable) - [x] Old-style mentions (NIP-08) - [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] Generic Tag Queries (NIP-12) - [x] Proof of Work Display (NIP-13) @@ -59,6 +79,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Nostr Connect (NIP-46) - [x] Wallet Connect API (NIP-47) - [ ] Proxy Tags (NIP-48, Not applicable) +- [x] Private key encryption for import/export (NIP-49) - [x] Online Relay Search (NIP-50) - [x] Lists (NIP-51) - [ ] Calendar Events (NIP-52) @@ -69,6 +90,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [x] Zaps (NIP-57) - [x] Private Zaps - [x] Zap Splits (NIP-57) +- [x] Gift Wraps & Seals (NIP-59) - [x] Zapraiser (NIP-TBD) - [x] Badges (NIP-58) - [ ] Relay List Metadata (NIP-65) @@ -79,13 +101,13 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [x] Highlights (NIP-84) - [x] Recommended Application Handlers (NIP-89) - [ ] Data Vending Machine (NIP-90) +- [x] Inline Metadata (NIP-92) - [x] Verifiable file URLs (NIP-94) - [x] Binary Blobs (NIP-95) - [x] HTTP File Storage Integration (NIP-96 Draft) - [x] HTTP Auth (NIP-98) - [x] Classifieds (NIP-99) - [x] Private Messages and Small Groups (NIP-24/Draft) -- [x] Gift Wraps & Seals (NIP-59/Draft) - [x] Versioned Encrypted Payloads (NIP-44/Draft) - [x] Audio Tracks (zapstr.live) (Kind:31337) - [x] Push Notifications (Google and Unified Push) @@ -101,40 +123,50 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Workspaces - [ ] 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 -## 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 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, +The app 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. -The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to -allow the UI and other parts of the app to subscribe to each individual Note/User and receive updates when they happen. -They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user -sees different Events, the Datasource classes are used to receive more information about those particular Events. +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 Note/User and receive updates when they happen. +They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes +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, receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app. -The UI receives a notification that objects were updated. Instances of User and Notes are mutable directly. +The UI receives a notification that objects have been updated. Instances of User and Notes are mutable directly. There will never be two Notes with the same ID or two User instances with the same pubkey. -Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security. +Lastly, the user's account information (private key/pub key) is stored in the Android KeyStore for security. ## Setup Make sure to have the following pre-requisites installed: -1. Java 17 +1. Java 17+ 2. Android Studio 3. Android 8.0+ Phone or Emulation setup -Fork and clone this repository and import into Android Studio +Fork and clone this repository and import it into Android Studio ```bash git clone https://github.com/vitorpamplona/amethyst.git ``` -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 Build the app: @@ -166,7 +198,7 @@ For the Play build: ./gradlew installPlayDebug ``` -## How to Deploy +## Deploying 1. Generate a new signing key ``` @@ -183,25 +215,37 @@ openssl base64 < | tr -d '\n' | tee some_signing_key.j 5. Tag the commit with `v{x.x.x}` 6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file. 7. Add your CHANGE LOG to the description of the new release -8. Download the `aab` file and upload it to the` PlayStore. +8. Download the `aab` file and upload it to the PlayStore. -# Privacy on Relays & nostr -Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online. +## Using the Quartz library -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 # -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. +```gradle +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + maven { url 'https://jitpack.io' } + } +} +``` + +Add the dependency -# Visibility & Permanence of Your Content on nostr -## 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. +```gradle +implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3') +``` -## Information Permanence ## -Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted. +## 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 @@ -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) | -# Contributing - -[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome. - -## Contributors +# Contributors @@ -221,6 +261,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th # MIT License +
 Copyright (c) 2023 Vitor Pamplona
 
 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,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
+
diff --git a/app/build.gradle b/app/build.gradle index e068e8fa1..7f6f5be40 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,9 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 348 - versionName "0.83.7" + versionCode 358 + versionName "0.84.3" + buildConfigField "String", "RELEASE_NOTES_ID", "\"4d5a05aec61d8798f30f76b2efab81b98d75a03f935fb82823a1080bd56473cd\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -26,7 +27,7 @@ android { 'cy-rGB', 'da-rDK', 'de', - 'el-rGB', + 'el-rGR', 'en-rGB', 'eo', 'es', @@ -141,13 +142,15 @@ android { } composeOptions { - kotlinCompilerExtensionVersion "1.5.3" + // Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion "1.5.8" } packagingOptions { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } + exclude '**/libscrypt.dylib' } lint { @@ -161,6 +164,7 @@ android { dependencies { implementation project(path: ':quartz') + implementation project(path: ':commons') implementation "androidx.core:core-ktx:$core_ktx_version" implementation 'androidx.activity:activity-compose:1.8.2' implementation "androidx.compose.ui:ui:$compose_ui_version" @@ -181,7 +185,7 @@ dependencies { // Adaptive Layout / Two Pane implementation "androidx.compose.material3:material3-window-size-class:${material3_version}" - implementation "com.google.accompanist:accompanist-adaptive:0.32.0" + implementation 'com.google.accompanist:accompanist-adaptive:0.34.0' // Lifecycle @@ -191,7 +195,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // Zoomable images - implementation 'net.engawapg.lib:zoomable:1.5.3' + implementation 'net.engawapg.lib:zoomable:1.6.0' // Biometrics implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" @@ -228,7 +232,7 @@ dependencies { implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" // For QR generation - implementation 'com.google.zxing:core:3.5.2' + implementation 'com.google.zxing:core:3.5.3' implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // Markdown @@ -251,7 +255,7 @@ dependencies { playImplementation 'com.google.mlkit:translate:17.0.2' // 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' //PushNotifications(FDroid) @@ -276,8 +280,8 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.13.9' - androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02' - androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02' + androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 97496bbf1..5b1f59d16 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -33,6 +33,11 @@ # JNA For Libsodium -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 -keep class com.sun.jna.ToNativeConverter { *; } -keep class com.sun.jna.NativeMapped { *; } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index d9d10c024..c87fb572a 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt new file mode 100644 index 000000000..f09f8964b --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt @@ -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()) + } +} diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt index 9c878d65e..39cf69711 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 3fa4da42a..92f0af91b 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt index ae00af4db..3d22f9ae0 100644 --- a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 8c12f0172..3b9ddaebe 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index f08af58f9..d7a28413f 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 951f6e8c6..b6313df50 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -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 * 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.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -60,6 +61,7 @@ class PushMessageReceiver : MessagingReceiver() { try { parseMessage(messageStr)?.let { receiveIfNew(it) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.d(TAG, "Message could not be parsed: ${e.message}") } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 6a25767b0..0b8f85f93 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -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 * 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 com.vitorpamplona.amethyst.AccountInfo +import kotlinx.coroutines.CancellationException object PushNotificationUtils { var hasInit: Boolean = false @@ -36,6 +37,7 @@ object PushNotificationUtils { RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 6fba6039e..c008764ca 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 7d211335a..fe225dcaf 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b30c56b1d..8b30f6ee1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,8 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> - - + + + + + diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt index c308bab1a..3e750ff23 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt index 8283d1735..5402915b8 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt index 7bf737ad9..68bfcd489 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt index cfadc039f..42a6a90f4 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index a16fde1b4..ad21a7f0f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -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 * 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.VmPolicy import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences import coil.ImageLoader import coil.disk.DiskCache +import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer +import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder import 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.Dispatchers import kotlinx.coroutines.GlobalScope @@ -52,7 +57,7 @@ class Amethyst : Application() { newCache } - private val imageCache: DiskCache by lazy { + val coilCache: DiskCache by lazy { DiskCache.Builder() .directory(applicationContext.safeCacheDir.resolve("image_cache")) .maxSizePercent(0.2) @@ -64,6 +69,8 @@ class Amethyst : Application() { super.onCreate() instance = this + OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder()) + if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( ThreadPolicy.Builder().detectAll().penaltyLog().build(), @@ -84,7 +91,11 @@ class Amethyst : Application() { } 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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 9d6b15dad..0768bfec1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,32 +20,35 @@ */ package com.vitorpamplona.amethyst +import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey -object EncryptedStorage { - private const val PREFERENCES_NAME = "secret_keeper" +class EncryptedStorage { + companion object { + private const val PREFERENCES_NAME = "secret_keeper" - // returns the preferences for each account or a global file if null. - fun prefsFileName(npub: String? = null): String { - return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" - } - - fun preferences(npub: String? = null): EncryptedSharedPreferences { - val context = Amethyst.instance - val masterKey: MasterKey = - MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + // returns the preferences for each account or a global file if null. + fun prefsFileName(npub: String? = null): String { + return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + } - val preferencesName = prefsFileName(npub) + fun preferences( + applicationContext: Context, + npub: String? = null, + ): EncryptedSharedPreferences { + val masterKey: MasterKey = + MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() - return EncryptedSharedPreferences.create( - context, - preferencesName, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) as EncryptedSharedPreferences + return EncryptedSharedPreferences.create( + applicationContext, + prefsFileName(npub), + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) as EncryptedSharedPreferences + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index e0f65780d..8de38a5f3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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 * 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.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS -import com.vitorpamplona.amethyst.model.Nip47URI import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseThemeType -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip47WalletConnect import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey 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.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex @@ -113,6 +115,8 @@ private object PrefKeys { const val SIGNER_PACKAGE_NAME = "signer_package_name" const val NEW_POST_DRAFT = "draft_new_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 SHARED_SETTINGS = "shared_settings" @@ -234,7 +238,7 @@ object LocalPreferences { if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) } else { - return EncryptedStorage.preferences(npub) + return Amethyst.instance.encryptedStorage(npub) } } @@ -332,12 +336,18 @@ object LocalPreferences { PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(account.lastReadPerRoute), ) + putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion) if (account.showSensitiveContent == null) { remove(PrefKeys.SHOW_SENSITIVE_CONTENT) } else { putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) } + + putString( + PrefKeys.PENDING_ATTESTATIONS, + Event.mapper.writeValueAsString(account.pendingAttestations), + ) } .apply() } @@ -370,6 +380,7 @@ object LocalPreferences { return try { getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue(it) } } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w( "LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", @@ -544,6 +555,7 @@ object LocalPreferences { } ?: Nip96MediaServers.DEFAULT[0] } catch (e: Exception) { + if (e is CancellationException) throw e Log.w("LocalPreferences", "Failed to decode saved File Server", e) e.printStackTrace() Nip96MediaServers.DEFAULT[0] @@ -552,9 +564,10 @@ object LocalPreferences { val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { - Event.mapper.readValue(it) + Event.mapper.readValue(it) } } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w( "LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", @@ -575,6 +588,27 @@ object LocalPreferences { } } } 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>(it) + } else { + null + } + } + } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w( "LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", @@ -590,6 +624,7 @@ object LocalPreferences { } ?: mapOf() } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w( "LocalPreferences", "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 useProxy = getBoolean(PrefKeys.USE_PROXY, false) 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 = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { @@ -622,6 +657,7 @@ object LocalPreferences { } ?: mapOf() } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w( "LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", @@ -644,6 +680,8 @@ object LocalPreferences { NostrSignerInternal(keyPair) } + val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() + val account = Account( keyPair = keyPair, @@ -671,6 +709,8 @@ object LocalPreferences { warnAboutPostsWithReports = warnAboutReports, filterSpamFromStrangers = filterSpam, lastReadPerRoute = lastReadPerRoute, + hasDonatedInVersion = hasDonatedInVersion, + pendingAttestations = pendingAttestations ?: emptyMap(), ) // Loads from DB diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 9e3fc4a48..d86cd26bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,7 +30,7 @@ import coil.decode.SvgDecoder import coil.size.Precision import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.toHexKey +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -83,7 +84,7 @@ class ServiceManager { val myAccount = account // Resets Proxy Use - HttpClient.start(account?.proxy) + HttpClientManager.setDefaultProxy(account?.proxy) LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true Coil.setImageLoader { Amethyst.instance @@ -96,7 +97,7 @@ class ServiceManager { } add(SvgDecoder.Factory()) } // .logger(DebugLogger()) - .okHttpClient { HttpClient.getHttpClient() } + .okHttpClient { HttpClientManager.getHttpClient() } .precision(Precision.INEXACT) .respectCacheHeaders(false) .build() @@ -126,6 +127,7 @@ class ServiceManager { try { it.npub.bechToBytes().toHexKey() } catch (e: Exception) { + if (e is CancellationException) throw e null } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5ad77ecac..c7421c35f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 * 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.switchMap import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.Nip96MediaServers 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.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip47WalletConnect import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey 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.MuteListEvent import com.vitorpamplona.quartz.events.NIP24Factory +import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.Price @@ -96,6 +99,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -170,7 +174,7 @@ class Account( var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), var defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47URI? = null, + var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP24WarningDialog: Boolean = false, @@ -181,10 +185,10 @@ class Account( var warnAboutPostsWithReports: Boolean = true, var filterSpamFromStrangers: Boolean = true, var lastReadPerRoute: Map = mapOf(), + var hasDonatedInVersion: Set = setOf(), + var pendingAttestations: Map = mapOf(), + val scope: CoroutineScope = Amethyst.instance.applicationIOScope, ) { - // Uses a single scope for the entire application. - val scope = Amethyst.instance.applicationIOScope - var transientHiddenUsers: ImmutableSet = persistentSetOf() data class PaymentRequest( @@ -727,7 +731,7 @@ class Account( zapResponseEvent.response(signer, onReady) } - fun calculateIfNoteWasZappedByAccount( + suspend fun calculateIfNoteWasZappedByAccount( zappedNote: Note?, 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) { if (!isWriteable()) return @@ -1166,7 +1202,7 @@ class Account( Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent, null) - return LocalCache.notes[signedEvent.id] + return LocalCache.getNoteIfExists(signedEvent.id) } fun consumeNip95( @@ -1176,7 +1212,7 @@ class Account( LocalCache.consume(data, null) LocalCache.consume(signedEvent, null) - return LocalCache.notes[signedEvent.id] + return LocalCache.getNoteIfExists(signedEvent.id) } fun sendNip95( @@ -1196,7 +1232,7 @@ class Account( Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent, null) - LocalCache.notes[signedEvent.id]?.let { onReady(it) } + LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } } fun createHeader( @@ -1323,9 +1359,10 @@ class Account( replyingTo: String?, root: String?, directMentions: Set, + forkedFrom: Event?, relayList: List? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1347,6 +1384,7 @@ class Account( directMentions = directMentions, geohash = geohash, nip94attachments = nip94attachments, + forkedFrom = forkedFrom, signer = signer, ) { Client.send(it, relayList = relayList) @@ -1381,7 +1419,7 @@ class Account( zapRaiserAmount: Long? = null, relayList: List? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1428,7 +1466,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1461,7 +1499,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1495,6 +1533,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, ) { sendPrivateMessage( message, @@ -1505,6 +1544,7 @@ class Account( wantsToMarkAsSensitive, zapRaiserAmount, geohash, + nip94attachments, ) } @@ -1517,6 +1557,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1533,6 +1574,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, signer = signer, advertiseNip18 = false, ) { @@ -1551,6 +1593,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1567,6 +1610,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, signer = signer, ) { broadcastPrivately(it) @@ -2031,7 +2075,7 @@ class Account( saveable.invalidateData() } - fun changeZapPaymentRequest(newServer: Nip47URI?) { + fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) { zapPaymentRequest = newServer live.invalidateData() saveable.invalidateData() @@ -2348,6 +2392,16 @@ class Account( 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() = withContext(Dispatchers.Main) { // saves contact list for the next time. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 31dcb1924..c0ddfa122 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -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 * 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.ui.components.BundledUpdate 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 kotlinx.coroutines.Dispatchers @@ -58,7 +58,7 @@ class AntiSpamFilter { // really long, make it ok. // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still // blocking repeated "lnbc..." invoices or fishing urls - if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false + if (event.content.length < 180 && Nip19Bech32.nip19regex.matcher(event.content).find()) return false // double list strategy: // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. @@ -71,7 +71,7 @@ class AntiSpamFilter { ) { Log.w( "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( "Potential SPAM Message", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 1d211d28b..8a5e53afb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt index a331fece9..7a22e1e24 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index fb40ae0d2..f3da2c1f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -32,6 +32,7 @@ fun checkForHashtagWithIcon( primary: Color, ): HashtagIcon? { return when (tag.lowercase()) { + "₿itcoin", "bitcoin", "btc", "timechain", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2122b7a99..f366531a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 * 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.ui.components.BundledInsert import com.vitorpamplona.quartz.encoders.ATag -import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey 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.toHexKey import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent 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.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelHideMessageEvent +import com.vitorpamplona.quartz.events.ChannelListEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent 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.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.CommunityListEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileServersEvent import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent 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.LiveActivitiesChatMessageEvent 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.MuteListEvent import com.vitorpamplona.quartz.events.NNSEvent +import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PinListEvent 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.VideoHorizontalEvent 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.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -109,18 +118,31 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.concurrent.ConcurrentHashMap +import kotlin.time.measureTimedValue object LocalCache { val antiSpam = AntiSpamFilter() - val users = ConcurrentHashMap(5000) - val notes = ConcurrentHashMap(5000) + private val users = ConcurrentHashMap(5000) + private val notes = ConcurrentHashMap(5000) val channels = ConcurrentHashMap() val addressables = ConcurrentHashMap(100) val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + var noteListCache: List = emptyList() + var userListCache: List = 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? { // checkNotInMainThread() @@ -357,6 +379,87 @@ object LocalCache { 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( event: LongTextNoteEvent, 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( event: PollNoteEvent, relay: Relay? = null, @@ -462,6 +600,27 @@ object LocalCache { 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( event: FileServersEvent, 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( event: BadgeDefinitionEvent, relay: Relay?, @@ -742,7 +923,7 @@ object LocalCache { event .deleteEvents() - .mapNotNull { notes[it] } + .mapNotNull { getNoteIfExists(it) } .forEach { deleteNote -> // must be the same author if (deleteNote.author?.pubkeyHex == event.pubKey) { @@ -1183,6 +1364,26 @@ object LocalCache { 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( event: HighlightEvent, relay: Relay?, @@ -1379,11 +1580,14 @@ object LocalCache { val key = decodePublicKeyAsHexOrNull(username) - if (key != null && users[key] != null) { - return listOfNotNull(users[key]) + if (key != null) { + val user = getUserIfExists(key) + if (user != null) { + return listOfNotNull(user) + } } - return users.values.filter { + return userListCache.filter { (it.anyNameStartsWith(username)) || it.pubkeyHex.startsWith(username, true) || it.pubkeyNpub().startsWith(username, true) @@ -1393,18 +1597,16 @@ object LocalCache { fun findNotesStartingWith(text: String): List { checkNotInMainThread() - val key = - try { - Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() - } catch (e: Exception) { - null - } + val key = decodeEventIdAsHexOrNull(text) - if (key != null && (notes[key] ?: addressables[key]) != null) { - return listOfNotNull(notes[key] ?: addressables[key]) + if (key != null) { + val note = getNoteIfExists(key) + if (note != null) { + return listOfNotNull(note) + } } - return notes.values.filter { + return noteListCache.filter { ( it.event !is GenericRepostEvent && it.event !is RepostEvent && @@ -1442,13 +1644,7 @@ object LocalCache { fun findChannelsStartingWith(text: String): List { checkNotInMainThread() - val key = - try { - Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() - } catch (e: Exception) { - null - } - + val key = decodeEventIdAsHexOrNull(text) if (key != null && channels[key] != null) { return listOfNotNull(channels[key]) } @@ -1479,12 +1675,32 @@ object LocalCache { .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() { - notes.forEach { it.value.clearLive() } + noteListCache.forEach { it.clearLive() } addressables.forEach { it.value.clearLive() } - users.forEach { it.value.clearLive() } + userListCache.forEach { it.clearLive() } } fun pruneOldAndHiddenMessages(account: Account) { @@ -1510,8 +1726,8 @@ object LocalCache { } } - users.forEach { userPair -> - userPair.value.privateChatrooms.values.map { + userListCache.forEach { userPair -> + userPair.privateChatrooms.values.map { val toBeRemoved = it.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() @@ -1526,7 +1742,7 @@ object LocalCache { if (toBeRemoved.size > 1) { 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() { val toBeRemoved = - notes + noteListCache .filter { - val noteEvent = it.value.event + val noteEvent = it.event if (noteEvent is AddressableEvent) { noteEvent.createdAt() < (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0) @@ -1545,7 +1761,6 @@ object LocalCache { false } } - .values val childrenToBeRemoved = mutableListOf() @@ -1570,24 +1785,23 @@ object LocalCache { checkNotInMainThread() val toBeRemoved = - notes + noteListCache .filter { ( - (it.value.event is TextNoteEvent && !it.value.isNewThread()) || - it.value.event is ReactionEvent || - it.value.event is LnZapEvent || - it.value.event is LnZapRequestEvent || - it.value.event is ReportEvent || - it.value.event is GenericRepostEvent + (it.event is TextNoteEvent && !it.isNewThread()) || + it.event is ReactionEvent || + it.event is LnZapEvent || + it.event is LnZapRequestEvent || + it.event is ReportEvent || + it.event is GenericRepostEvent ) && - it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true && - it.value.liveSet?.isInUse() != true && // don't delete if observing. - it.value.author?.pubkeyHex !in + it.replyTo?.any { it.liveSet?.isInUse() == true } != true && + it.liveSet?.isInUse() != true && // don't delete if observing. + it.author?.pubkeyHex !in 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 } - .values val childrenToBeRemoved = mutableListOf() @@ -1653,7 +1867,8 @@ object LocalCache { fun pruneExpiredEvents() { 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() @@ -1679,7 +1894,7 @@ object LocalCache { ?.hiddenUsers ?.map { userHex -> ( - notes.values.filter { it.event?.pubKey() == userHex } + + noteListCache.filter { it.event?.pubKey() == userHex } + addressables.values.filter { it.event?.pubKey() == userHex } ) .toSet() @@ -1701,7 +1916,7 @@ object LocalCache { checkNotInMainThread() var removingContactList = 0 - users.values.forEach { + userListCache.forEach { if ( it.pubkeyHex !in loggedIn && (it.liveSet == null || it.liveSet?.isInUse() == false) && @@ -1738,6 +1953,7 @@ object LocalCache { try { event.checkSignature() } catch (e: Exception) { + if (e is CancellationException) throw e Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) } false @@ -1768,6 +1984,7 @@ object LocalCache { is CalendarTimeSlotEvent -> consume(event, relay) is CalendarRSVPEvent -> consume(event, relay) is ChannelCreateEvent -> consume(event) + is ChannelListEvent -> consume(event, relay) is ChannelHideMessageEvent -> consume(event) is ChannelMessageEvent -> consume(event, relay) is ChannelMetadataEvent -> consume(event) @@ -1775,6 +1992,7 @@ object LocalCache { is ChatMessageEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay) is CommunityDefinitionEvent -> consume(event, relay) + is CommunityListEvent -> consume(event, relay) is CommunityPostApprovalEvent -> { event.containedPost()?.let { verifyAndConsume(it, relay) } consume(event) @@ -1784,11 +2002,16 @@ object LocalCache { is EmojiPackEvent -> consume(event, relay) is EmojiPackSelectionEvent -> consume(event, relay) is SealedGossipEvent -> consume(event, relay) + is FhirResourceEvent -> consume(event, relay) is FileHeaderEvent -> consume(event, relay) is FileServersEvent -> consume(event, relay) is FileStorageEvent -> consume(event, relay) is FileStorageHeaderEvent -> 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 LiveActivitiesEvent -> consume(event, relay) is LiveActivitiesChatMessageEvent -> consume(event, relay) @@ -1806,6 +2029,7 @@ object LocalCache { is MetadataEvent -> consume(event) is MuteListEvent -> consume(event, relay) is NNSEvent -> comsume(event, relay) + is OtsEvent -> consume(event, relay) is PrivateDmEvent -> consume(event, relay) is PinListEvent -> consume(event, relay) is PeopleListEvent -> consume(event, relay) @@ -1826,11 +2050,13 @@ object LocalCache { is TextNoteEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay) + is WikiNoteEvent -> consume(event, relay) else -> { Log.w("Event Not Supported", event.toJson()) } } } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() } } @@ -1845,6 +2071,10 @@ class LocalCacheLiveData { private val bundler = BundledInsert(1000, Dispatchers.IO) fun invalidateData(newNote: Note) { - bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } + bundler.invalidateList(newNote) { + bundledNewNotes -> + _newEventBundles.emit(bundledNewNotes) + LocalCache.updateListCache() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 5d2a49f9a..73526c322 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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 * 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.HexKey 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.events.AddressableEvent 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.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse 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.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import kotlin.coroutines.resume @Stable class AddressableNote(val address: ATag) : Note(address.toTag()) { @@ -137,17 +142,17 @@ open class Note(val idHex: String) { return if (myEvent is WrappedEvent) { val host = myEvent.host if (host != null) { - Nip19.createNEvent( + Nip19Bech32.createNEvent( host.id, host.pubKey, host.kind(), relays.firstOrNull()?.url, ) } else { - Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) } } 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?, user: User, account: Account, @@ -531,48 +536,65 @@ open class Note(val idHex: String) { 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) { - onWasZappedByAuthor() - } else { - account.decryptZapContentAuthor(next.first) { - if ( - it.pubKey == user.pubkeyHex && - (option == null || option == (it as? LnZapEvent)?.zappedPollOption()) - ) { + if (!zapRequest.isPrivateZap()) { + // public events + if (zapRequest.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) { onWasZappedByAuthor() + return + } + } else { + // private events + + // if has already decrypted + val privateZap = zapRequest.cachedPrivateZap() + if (privateZap != null) { + if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) { + onWasZappedByAuthor() + return + } } else { - recursiveIsZappedByCalculation( - option, - user, - account, - remainingZapEvents.minus(next), - onWasZappedByAuthor, - ) + if (account.isWriteable()) { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + zapRequest.decryptPrivateZap(account.signer) { + continuation.resume(it) + } + } + } + + if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) { + onWasZappedByAuthor() + return + } + } } } } } - fun isZappedBy( + suspend fun isZappedBy( user: User, account: Account, onWasZappedByAuthor: () -> Unit, ) { - recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) + isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) if (account.userProfile() == user) { recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) } } - fun isZappedBy( + suspend fun isZappedBy( option: Int?, user: User, account: Account, onWasZappedByAuthor: () -> Unit, ) { - recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) + isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) } fun getReactionBy(user: User): String? { @@ -614,7 +636,7 @@ open class Note(val idHex: String) { // Regular Zap Receipts zaps.forEach { - val noteEvent = it?.value?.event + val noteEvent = it.value?.event if (noteEvent is LnZapEvent) { sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO } @@ -644,6 +666,7 @@ open class Note(val idHex: String) { try { LnInvoiceUtil.getAmountInSats(invoice) } catch (e: java.lang.Exception) { + if (e is CancellationException) throw e null } @@ -694,6 +717,7 @@ open class Note(val idHex: String) { try { BigDecimal(it.event?.content()) } catch (e: Exception) { + if (e is CancellationException) throw e null // do nothing if it can't convert to bigdecimal } @@ -709,6 +733,7 @@ open class Note(val idHex: String) { try { BigDecimal(it.event?.content()) } catch (e: Exception) { + if (e is CancellationException) throw e null // do nothing if it can't convert to bigdecimal } @@ -922,6 +947,7 @@ class NoteLiveSet(u: Note) { val innerReports = NoteBundledRefresherLiveData(u) val innerRelays = NoteBundledRefresherLiveData(u) val innerZaps = NoteBundledRefresherLiveData(u) + val innerOts = NoteBundledRefresherLiveData(u) val metadata = innerMetadata.map { it } val reactions = innerReactions.map { it } @@ -986,6 +1012,7 @@ class NoteLiveSet(u: Note) { innerReports.destroy() innerRelays.destroy() innerZaps.destroy() + innerOts.destroy() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt index fd4a63b9a..db99be861 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt index 1aa95119c..d0b945791 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt index e1f0881ff..3da6deae5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index a24c2cfbe..2dfb7d9cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index f55e0ec12..09edd78e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 23f68e951..30ded640a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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 * 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 new contact list (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData() } (latestContactList)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData() } liveSet?.innerRelays?.invalidateData() @@ -363,7 +363,7 @@ class User(val pubkeyHex: String) { } 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 { @@ -387,7 +387,7 @@ class User(val pubkeyHex: String) { } 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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt index 7ab7f8735..c39688628 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index 94025bee9..fd18a2415 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index c6097c5a4..25e6c6050 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,40 +20,10 @@ */ package com.vitorpamplona.amethyst.service -import android.util.Log import android.util.LruCache -import android.util.Patterns -import androidx.compose.runtime.Immutable -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.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.RichTextViewerState 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, - val imagesForPager: ImmutableMap, - val imageList: ImmutableList, - val customEmoji: ImmutableMap, - val paragraphs: ImmutableList, -) - -data class ParagraphState(val words: ImmutableList, val isRTL: Boolean) object CachedRichTextParser { val richTextCache = LruCache(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, - ): 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, - urls: Set, - emojis: Map, - tags: ImmutableListOfLists, - ): ImmutableList { - var paragraphSegments = persistentListOf() - - content.split('\n').forEach { paragraph -> - var segments = persistentListOf() - 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(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, - urls: Set, - emojis: Map, - tags: ImmutableListOfLists, - ): 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, - ): 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) } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index f19a4cf6a..ecd1918f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -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 * 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.RequestBody.Companion.toRequestBody import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException @Immutable data class CashuToken( @@ -59,6 +60,7 @@ class CashuProcessor { return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) } catch (e: Exception) { + if (e is CancellationException) throw e return GenericLoadable.Error("Could not parse this cashu token") } } @@ -119,7 +121,7 @@ class CashuProcessor { checkNotInMainThread() try { - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint val factory = Event.mapper.nodeFactory @@ -154,6 +156,7 @@ class CashuProcessor { } } } catch (e: Exception) { + if (e is CancellationException) throw e onError( context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), @@ -170,7 +173,7 @@ class CashuProcessor { context: Context, ) { try { - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() val url = token.mint + "/melt" // Melt cashu tokens at Mint val factory = Event.mapper.nodeFactory @@ -211,6 +214,7 @@ class CashuProcessor { } } } catch (e: Exception) { + if (e is CancellationException) throw e onError( context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt index 5ee9de38b..8f1e1ab60 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index f763482d0..cdb33e6d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -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 * 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.encoders.toHexKey import io.trbl.blurhash.BlurHash +import kotlinx.coroutines.CancellationException import java.io.IOException import kotlin.math.roundToInt @@ -59,6 +60,7 @@ class FileHeader( onError(null) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") onError(e.message) } @@ -174,6 +176,7 @@ class FileHeader( onReady(FileHeader(mimeType, hash, size, dim, blurHash)) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") onError(e.message) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClientManager.kt similarity index 76% rename from app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt rename to app/src/main/java/com/vitorpamplona/amethyst/service/HttpClientManager.kt index 7645f8e1e..ea504dae2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClientManager.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -32,14 +32,13 @@ import java.net.Proxy import java.time.Duration import kotlin.properties.Delegates -object HttpClient { - val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) - val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) +object HttpClientManager { + val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L) + val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L) var proxyChangeListeners = ArrayList<() -> Unit>() - var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI - - var defaultHttpClient: OkHttpClient? = null + private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI + private var defaultHttpClient: OkHttpClient? = null // fires off every time value of the property changes private var internalProxy: Proxy? by @@ -49,26 +48,34 @@ object HttpClient { } } - fun start(proxy: Proxy?) { + fun setDefaultProxy(proxy: Proxy?) { if (internalProxy != proxy) { + Log.d("HttpClient", "Changing proxy to: ${proxy != null}") 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") if (this.defaultTimeout.seconds != timeout.seconds) { this.defaultTimeout = timeout - this.defaultHttpClient = getHttpClient() + + // recreates singleton + this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout) } } - fun getHttpClient(timeout: Duration): OkHttpClient { - val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds + private fun buildHttpClient( + proxy: Proxy?, + timeout: Duration, + ): OkHttpClient { + val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds val duration = Duration.ofSeconds(seconds) return OkHttpClient.Builder() - .proxy(internalProxy) + .proxy(proxy) .readTimeout(duration) .connectTimeout(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 { if (this.defaultHttpClient == null) { - this.defaultHttpClient = getHttpClient(defaultTimeout) + this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout) } return defaultHttpClient!! } - fun getProxy(): Proxy? { - return internalProxy - } - fun initProxy( useProxy: Boolean, hostname: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt index f634b08a6..cd741c52a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -29,6 +29,7 @@ import android.location.LocationManager import android.os.HandlerThread import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow class LocationUtil(context: Context) { @@ -105,6 +106,7 @@ class ReverseGeoLocationUtil { .joinToString(", ") } } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() return null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt index 67c78f792..8d027dbdb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index fa97b0a93..674e8fa0f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -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 * 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.vitorpamplona.amethyst.BuildConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Call @@ -64,7 +65,7 @@ class Nip05NostrAddressVerifier() { .url(url) .build() - HttpClient.getHttpClient() + HttpClientManager.getHttpClient() .newCall(request) .enqueue( 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}") } } @@ -122,7 +124,8 @@ class Nip05NostrAddressVerifier() { val nip05url = try { 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") null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index 93815bf0e..fc5c5a1ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -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 * 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.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.Callback import okhttp3.Request @@ -30,29 +32,64 @@ import okhttp3.Response import java.io.IOException object Nip11CachedRetriever { - val relayInformationDocumentCache = LruCache(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(100) 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( dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, + onInfo: (Nip11RelayInformation) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, ) { val url = retriever.cleanUrl(dirtyUrl) val doc = relayInformationDocumentCache.get(url) 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 { + 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) + }, + ) + } + } } else { Nip11Retriever() .loadRelayInfo( - url, - dirtyUrl, + url = url, + dirtyUrl = dirtyUrl, onInfo = { - relayInformationDocumentCache.put(url, it) + relayInformationDocumentCache.put(url, RetrieveResultSuccess(it)) onInfo(it) }, - onError, + onError = { dirtyUrl, code, errorMsg -> + relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg)) + onError(url, code, errorMsg) + }, ) } } @@ -77,14 +114,15 @@ class Nip11Retriever { suspend fun loadRelayInfo( url: String, dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, + onInfo: (Nip11RelayInformation) -> Unit, onError: (String, ErrorCode, String?) -> Unit, ) { + checkNotInMainThread() try { val request: Request = Request.Builder().header("Accept", "application/nostr+json").url(url).build() - HttpClient.getHttpClient() + HttpClientManager.getHttpClient() .newCall(request) .enqueue( object : Callback { @@ -97,11 +135,12 @@ class Nip11Retriever { val body = it.body.string() try { if (it.isSuccessful) { - onInfo(RelayInformation.fromJson(body)) + onInfo(Nip11RelayInformation.fromJson(body)) } else { onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e( "RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", @@ -122,6 +161,7 @@ class Nip11Retriever { }, ) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt deleted file mode 100644 index 075b537cc..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt index be1fac14f..8c5ed317d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt @@ -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 * 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.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kotlinx.coroutines.CancellationException import okhttp3.Request object Nip96MediaServers { @@ -87,7 +88,7 @@ class Nip96Retriever { .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") .build() - HttpClient.getHttpClient().newCall(request).execute().use { response -> + HttpClientManager.getHttpClient().newCall(request).execute().use { response -> checkNotInMainThread() response.use { val body = it.body.string() @@ -100,6 +101,7 @@ class Nip96Retriever { ) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) throw e } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index c4552a3a0..d4c51113d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -128,7 +128,7 @@ class Nip96Uploader(val account: Account?) { val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() val requestBody: RequestBody val requestBuilder = Request.Builder() @@ -194,7 +194,7 @@ class Nip96Uploader(val account: Account?) { val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() val requestBuilder = Request.Builder() @@ -227,7 +227,7 @@ class Nip96Uploader(val account: Account?) { server: Nip96Retriever.ServerInfo, onProgress: (percentage: Float) -> Unit, ): PartialEvent { - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() var currentResult = result while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index f9fd7daba..cedf0bd4f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index b72f8ecd6..8b820d284 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index 0adc07472..b6680fc9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index f567be888..386d78b6f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt index 955ee622f..a4e0aacec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index c672d6cca..47cb0af5d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -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 * 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]) { Client.close(updatedSubscription.id) if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", - ) Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) } } else { // hasn't changed, does nothing. if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", - ) Client.sendFilterOnlyIfDisconnected( updatedSubscription.id, updatedSubscriptionNewFilters, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index 07a795216..2a94fd7aa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt index 2daaef580..bba1e8c59 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index fa3f87f36..49f245d6a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index c3a0254be..402227cb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt index adb340c95..0cd136ccb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index a4a3a39ec..e7c2d66da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -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 * 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.JsonFilter 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.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.events.AudioHeaderEvent 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.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import kotlin.coroutines.cancellation.CancellationException object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { private var searchString: String? = null @@ -65,82 +69,118 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { 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) { + if (e is CancellationException) throw e null } - // downloads all the reactions to a given event. - return listOfNotNull( + val directReferenceFilters = 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( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = listOfNotNull(hexToWatch), + ), + ), + // authors + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOfNotNull(hexToWatch), + // just to be sure + limit = 5, + ), + ), + ) + } + } ?: emptyList() + + // downloads all the reactions to a given event. + return directReferenceFilters + + listOfNotNull( + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + search = mySearchString, + limit = 100, + ), + ), TypedFilter( - types = COMMON_FEED_TYPES, + types = setOf(FeedType.SEARCH), filter = JsonFilter( - ids = listOfNotNull(hexToWatch), + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, + ), + search = mySearchString, + limit = 100, ), - ) - }, - hexToWatch?.let { + ), TypedFilter( - types = COMMON_FEED_TYPES, + types = setOf(FeedType.SEARCH), filter = JsonFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(hexToWatch), + kinds = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PollNoteEvent.KIND, + NNSEvent.KIND, + ), + search = mySearchString, + limit = 100, ), - ) - }, - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - BadgeDefinitionEvent.KIND, - PeopleListEvent.KIND, - BookmarkListEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - ChannelCreateEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - JsonFilter( - kinds = - listOf( - ChannelMetadataEvent.KIND, - ClassifiedsEvent.KIND, - CommunityDefinitionEvent.KIND, - EmojiPackEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesEvent.KIND, - PollNoteEvent.KIND, - NNSEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - ) + ), + ) } val searchChannel = requestNewChannel() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index 364460ab9..beacb0564 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 3b4239a6d..e29566892 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -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 * 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.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReportEvent @@ -120,27 +121,51 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { } return groupByEOSEPresence(eventsToWatch).map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - ), - tags = mapOf("e" to it.map { it.idHex }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), + listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + OtsEvent.KIND, + ), + tags = mapOf("e" to it.map { it.idHex }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), ) + }.flatten() + } + + private fun createQuotesFilter(): List? { + 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? { @@ -205,9 +230,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { val missing = createLoadEventsIfNotLoadedFilter() val addresses = createAddressFilter() val addressReactions = createReactionsToWatchInAddressFilter() + val quotes = createQuotesFilter() singleEventChannel.typedFilters = - listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } + listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null } } fun add(eventId: Note) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index cf7adbb73..472be2ace 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index b7d71fa81..4d6d730a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 5f0631cde..2104df183 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 393c81fd7..8d810907f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 937efa8c4..fd508b905 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -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 * 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 com.vitorpamplona.amethyst.BuildConfig import okhttp3.Request +import kotlin.coroutines.cancellation.CancellationException @Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean) @@ -59,13 +60,14 @@ object OnlineChecker { .build() val result = - HttpClient.getHttpClient().newCall(request).execute().use { + HttpClientManager.getHttpClient().newCall(request).execute().use { checkNotInMainThread() it.isSuccessful } checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) result } catch (e: Exception) { + if (e is CancellationException) throw e checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) Log.e("LiveActivities", "Failed to check streaming url $url", e) false diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt index f7767cd8f..07a978d2e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service import android.content.Context +import android.content.Intent +import android.net.Uri object PackageUtils { private fun isPackageInstalled( @@ -36,7 +38,13 @@ object PackageUtils { return isPackageInstalled(context, "org.torproject.android") } - fun isAmberInstalled(context: Context): Boolean { - return isPackageInstalled(context, "com.greenart7c3.nostrsigner") + fun isExternalSignerInstalled(context: Context): Boolean { + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse("nostrsigner:") + } + val infos = context.packageManager.queryIntentActivities(intent, 0) + return infos.size > 0 } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index ea1d1f096..06de8548b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 1615a0dab..48c0d4acc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -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 * 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.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.Lud06 @@ -33,9 +33,10 @@ import okhttp3.Request import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder +import kotlin.coroutines.cancellation.CancellationException class LightningAddressResolver() { - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() fun assembleUrl(lnaddress: String): String? { val parts = lnaddress.split("@") @@ -96,14 +97,16 @@ class LightningAddressResolver() { } } } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() onError( context.getString(R.string.error_unable_to_fetch_invoice), context.getString( R.string - .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct, + .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception, url, lnaddress, + e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message, ), ) } @@ -183,6 +186,7 @@ class LightningAddressResolver() { try { mapper.readTree(lnAddressJson) } catch (t: Throwable) { + if (t is CancellationException) throw t onError( context.getString(R.string.error_unable_to_fetch_invoice), context.getString( @@ -218,6 +222,7 @@ class LightningAddressResolver() { try { mapper.readTree(it) } catch (t: Throwable) { + if (t is CancellationException) throw t onError( context.getString(R.string.error_unable_to_fetch_invoice), context.getString( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt index 8ba7f66cc..fcf530c0d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index edaa7ff4c..274bb5c87 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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 * 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 ) { // 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 followingKeySet = acc.followingKeySet() @@ -145,7 +145,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { event: PrivateDmEvent, acc: Account, ) { - val note = LocalCache.notes[event.id] ?: return + val note = LocalCache.getNoteIfExists(event.id) ?: return // old event being re-broadcast if (event.createdAt < TimeUtils.fiveMinutesAgo()) return @@ -184,7 +184,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { event: LnZapEvent, acc: Account, ) { - val noteZapEvent = LocalCache.notes[event.id] ?: return + val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return // old event being re-broadcast if (event.createdAt < TimeUtils.fiveMinutesAgo()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index a0f8c4b36..834c04052 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 53df87b4d..2049d817d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -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 * 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.LocalPreferences import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.quartz.events.RelayAuthEvent +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -106,10 +107,11 @@ class RegisterAccounts( .post(body) .build() - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() val isSucess = client.newCall(request).execute().use { it.isSuccessful } } catch (e: java.lang.Exception) { + if (e is CancellationException) throw e val tag = if (BuildConfig.FLAVOR == "play") { "FirebaseMsgService" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt new file mode 100644 index 000000000..9b074670a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt @@ -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" + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt new file mode 100644 index 000000000..f14d5048a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt new file mode 100644 index 000000000..be082413f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -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>? = null + + fun setQueue(queue: BlockingQueue>?) { + this.queue = queue + } + + @Throws(Exception::class) + override fun call(): Optional { + 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() + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt similarity index 65% rename from app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt rename to app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt index e76c2fffb..b8f9408a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt @@ -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 * 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 * 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) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt index 83daaecd2..8a9ef89bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt index 94499d3c9..d2c9b7bec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt @@ -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 * 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.SessionToken import com.google.common.util.concurrent.MoreExecutors +import kotlinx.coroutines.CancellationException object PlaybackClientController { val cache = LruCache(1) @@ -62,12 +63,14 @@ object PlaybackClientController { try { onReady(controllerFuture.get()) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) } }, MoreExecutors.directExecutor(), ) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt index 82dfc93b2..5ea55e77d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt @@ -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 * 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.MediaSessionService import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager @UnstableApi // Extend MediaSessionService class PlaybackService : MediaSessionService() { @@ -42,12 +42,12 @@ class PlaybackService : MediaSessionService() { private var managerLocal: MultiPlayerPlaybackManager? = null fun newHslDataSource(): MediaSource.Factory { - return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) + return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient())) } fun newProgressiveDataSource(): MediaSource.Factory { return ProgressiveMediaSource.Factory( - (applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()), + (applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()), ) } @@ -90,7 +90,7 @@ class PlaybackService : MediaSessionService() { Log.d("Lifetime Event", "PlaybackService.onCreate") // Stop all videos and recreates all managers when the proxy changes. - HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) + HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) } private fun onProxyUpdated() { @@ -114,7 +114,7 @@ class PlaybackService : MediaSessionService() { override fun onDestroy() { Log.d("Lifetime Event", "PlaybackService.onDestroy") - HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) + HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) managerHls?.releaseAppPlayers() managerLocal?.releaseAppPlayers() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt index 5f2bf1083..c4db43820 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt index df600ec98..e0867cd1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt index aedb40df6..24685a097 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.previews +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -29,6 +30,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) { try { fetch(timeOut) } catch (t: Throwable) { + if (t is CancellationException) throw t callback?.onFailed(t) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt index db5e6f2ba..6bfdacd8d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt index 3135f2285..5f5fb186d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt index ae521cfee..ab0e1f9b4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,7 +20,8 @@ */ package com.vitorpamplona.amethyst.service.previews -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager +import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType @@ -90,7 +91,8 @@ suspend fun getDocument( ): UrlInfoItem = withContext(Dispatchers.IO) { 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) { val mimeType = it.headers.get("Content-Type")?.toMediaType() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index e16a07c30..4bf85b83a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -108,7 +108,7 @@ object Client : RelayPool.Listener { checkNotInMainThread() subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) + RelayPool.connectAndSendFiltersIfDisconnected() } fun send( @@ -292,7 +292,7 @@ object Client : RelayPool.Listener { open fun onRelayStateChange( type: Relay.StateType, relay: Relay, - channel: String?, + subscriptionId: String?, ) = Unit /** When an relay saves or rejects a new event. */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt index 7c1cb3285..3cf37d874 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 964bf777c..a99a83878 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt index f2e6d6a43..dc7d66d9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 9b9c667f0..04a6aaaa2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -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 * 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 com.vitorpamplona.amethyst.BuildConfig 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.quartz.encoders.HexKey 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.bytesUsedInMemory import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket @@ -62,7 +63,7 @@ class Relay( const val RECONNECTING_IN_SECONDS = 60 * 3 } - private val httpClient = HttpClient.getHttpClientForRelays() + private val httpClient = HttpClientManager.getHttpClient() private var listeners = setOf() private var socket: WebSocket? = null @@ -76,7 +77,7 @@ class Relay( var errorCounter = 0 var pingInMs: Long? = null - var closingTimeInSeconds = 0L + var lastConnectTentative: Long = 0L var afterEOSEPerSubscription = mutableMapOf() @@ -106,7 +107,7 @@ class Relay( private var connectingBlock = AtomicBoolean() 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 :( if (url.contains("brb.io")) return @@ -119,6 +120,8 @@ class Relay( if (socket != null) return + lastConnectTentative = TimeUtils.now() + try { val request = Request.Builder() @@ -128,6 +131,8 @@ class Relay( socket = httpClient.newWebSocket(request, RelayListener(onConnected)) } catch (e: Exception) { + if (e is CancellationException) throw e + errorCounter++ markConnectionAsClosed() Log.e("Relay", "Relay Invalid $url") @@ -167,8 +172,9 @@ class Relay( try { processNewRelayMessage(text) - } catch (t: Throwable) { - t.printStackTrace() + } catch (e: Throwable) { + if (e is CancellationException) throw e + e.printStackTrace() text.chunked(2000) { chunked -> listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } } @@ -247,7 +253,6 @@ class Relay( this.isReady = false this.usingCompression = false this.resetEOSEStatuses() - this.closingTimeInSeconds = TimeUtils.now() } fun processNewRelayMessage(newMessage: String) { @@ -327,7 +332,7 @@ class Relay( Log.d("Relay", "Relay.disconnect $url") checkNotInMainThread() - closingTimeInSeconds = TimeUtils.now() + lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. socket?.cancel() socket = null isReady = false @@ -369,7 +374,7 @@ class Relay( } } else { // 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. connect() } @@ -404,12 +409,12 @@ class Relay( return buffer.toString() } - fun sendFilterOnlyIfDisconnected(subscriptionId: String) { + fun connectAndSendFiltersIfDisconnected() { checkNotInMainThread() if (socket == null) { // 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} ") connect() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index be90cb9ba..08135e9bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -81,8 +81,8 @@ object RelayPool : Relay.Listener { relays.forEach { it.sendFilter(subscriptionId) } } - fun sendFilterOnlyIfDisconnected(subscriptionId: String) { - relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) } + fun connectAndSendFiltersIfDisconnected() { + relays.forEach { it.connectAndSendFiltersIfDisconnected() } } fun sendToSelectedRelays( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt index 0c7ac6535..eefe41b99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt index a7b42588e..84ef11d26 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt index ffa864fa4..5c920ed8b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt index c6922198d..26de2fed8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 21402b7a6..6497e217e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -45,25 +45,27 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.debugState -import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.encoders.Nip47WalletConnect import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.PrivateDmEvent +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -112,7 +114,9 @@ class MainActivity : AppCompatActivity() { val accountStateViewModel: AccountStateViewModel = viewModel() accountStateViewModel.serviceManager = serviceManager - LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() } + LaunchedEffect(key1 = Unit) { + accountStateViewModel.tryLoginExistingAccountAsync() + } AccountScreen(accountStateViewModel, sharedPreferencesViewModel) } @@ -157,7 +161,9 @@ class MainActivity : AppCompatActivity() { override fun onPause() { Log.d("Lifetime Event", "MainActivity.onPause") - LanguageTranslatorService.clear() + GlobalScope.launch(Dispatchers.IO) { + LanguageTranslatorService.clear() + } serviceManager.cleanObservers() // if (BuildConfig.DEBUG) { @@ -236,9 +242,9 @@ class MainActivity : AppCompatActivity() { if (changedNetwork) { if (isOnMobileData) { - HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE) + HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE) } else { - HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI) + HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI) } } @@ -306,11 +312,12 @@ fun uriToRoute(uri: String?): String? { if (uri?.startsWith("nostr:Hashtag?id=") == true) { Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) } else { - val nip19 = Nip19.uriToRoute(uri) - when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> { + val nip19 = Nip19Bech32.uriToRoute(uri)?.entity + when (nip19) { + is Nip19Bech32.NPub -> "User/${nip19.hex}" + is Nip19Bech32.NProfile -> "User/${nip19.hex}" + is Nip19Bech32.Note -> "Note/${nip19.hex}" + is Nip19Bech32.NEvent -> { if (nip19.kind == PrivateDmEvent.KIND) { nip19.author?.let { "RoomByAuthor/$it" } } else if ( @@ -323,24 +330,32 @@ fun uriToRoute(uri: String?): String? { "Event/${nip19.hex}" } } - Nip19.Type.ADDRESS -> + is Nip19Bech32.NAddress -> { if (nip19.kind == CommunityDefinitionEvent.KIND) { - "Community/${nip19.hex}" + "Community/${nip19.atag}" } else if (nip19.kind == LiveActivitiesEvent.KIND) { - "Channel/${nip19.hex}" + "Channel/${nip19.atag}" } else { - "Event/${nip19.hex}" + "Event/${nip19.atag}" + } + } + is Nip19Bech32.NEmbed -> { + if (LocalCache.getNoteIfExists(nip19.event.id) == null) { + LocalCache.verifyAndConsume(nip19.event, null) } + "Event/${nip19.event.id}" + } else -> null } } ?: try { uri?.let { - Nip47WalletConnectParser.parse(it) + Nip47WalletConnect.parse(it) val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) Route.Home.base + "?nip47=" + encodedUri } } catch (e: Exception) { + if (e is CancellationException) throw e null } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt index e37758f29..6fa169566 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import java.net.HttpURLConnection import java.net.URL @@ -57,6 +58,7 @@ class ImageDownloader { null } } catch (e: Exception) { + if (e is CancellationException) throw e tentatives++ delay(1000) null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index d9e47197f..997a94608 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,7 +30,8 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager +import kotlinx.coroutines.CancellationException import okhttp3.Call import okhttp3.Callback import okhttp3.Request @@ -55,7 +56,7 @@ object ImageSaver { onSuccess: () -> Any?, onError: (Throwable) -> Any?, ) { - val client = HttpClient.getHttpClient() + val client = HttpClientManager.getHttpClient() val request = Request.Builder() @@ -102,6 +103,7 @@ object ImageSaver { } onSuccess() } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() onError(e) } @@ -138,6 +140,7 @@ object ImageSaver { } onSuccess() } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() onError(e) } @@ -176,6 +179,7 @@ object ImageSaver { outputStream.use { contentSource.readAll(it.sink()) } } catch (e: Exception) { + if (e is CancellationException) throw e contentResolver.delete(uri, null, null) throw e } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt index 4d6c87adb..f2cc82d6c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index a88464bf9..8406c09bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index 104e0ca66..15f5cf8f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 29fa75b87..25b42c4fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 985915881..5f89c0d01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -148,6 +149,7 @@ open class NewMediaModel : ViewModel() { context, ) } catch (e: Exception) { + if (e is CancellationException) throw e isUploadingImage = false uploadingPercentage.value = 0.00f uploadingDescription.value = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 0fde17743..dcb217e38 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -231,6 +232,7 @@ fun ImageVideoPost( try { bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) } catch (e: Exception) { + if (e is CancellationException) throw e Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) } } @@ -279,6 +281,7 @@ fun ImageVideoPost( modifier = Modifier.fillMaxWidth(), ) { SettingSwitchItem( + modifier = Modifier.fillMaxWidth().padding(8.dp), checked = postViewModel.sensitiveContent, onCheckedChange = { postViewModel.sensitiveContent = it }, title = R.string.add_sensitive_content_label, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 8299421e9..f7a3b5366 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -26,9 +26,10 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Bech32 import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.toNpub +import kotlinx.coroutines.CancellationException class NewMessageTagger( var message: String, @@ -67,17 +68,23 @@ class NewMessageTagger( paragraph.split(' ').forEach { word: String -> val results = parseDirtyWordForKey(word) - if (results?.key?.type == Nip19.Type.USER) { - addUserToMentions(dao.getOrCreateUser(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.NOTE) { - addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.EVENT) { - addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.ADDRESS) { - val note = dao.checkGetOrCreateAddressableNote(results.key.hex) - if (note != null) { - addNoteToReplyTos(note) + when (val entity = results?.key?.entity) { + is Nip19Bech32.NPub -> addUserToMentions(dao.getOrCreateUser(entity.hex)) + is Nip19Bech32.NProfile -> addUserToMentions(dao.getOrCreateUser(entity.hex)) + + is Nip19Bech32.Note -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) + is Nip19Bech32.NEvent -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) + is Nip19Bech32.NEmbed -> addNoteToReplyTos(dao.getOrCreateNote(entity.event.id)) + + is Nip19Bech32.NAddress -> { + val note = dao.checkGetOrCreateAddressableNote(entity.atag) + if (note != null) { + addNoteToReplyTos(note) + } } + + is Nip19Bech32.NSec -> {} + is Nip19Bech32.NRelay -> {} } } } @@ -91,27 +98,45 @@ class NewMessageTagger( .split(' ') .map { word: String -> val results = parseDirtyWordForKey(word) - if (results?.key?.type == Nip19.Type.USER) { - val user = dao.getOrCreateUser(results.key.hex) - - getNostrAddress(user.pubkeyNpub(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.NOTE) { - val note = dao.getOrCreateNote(results.key.hex) - - getNostrAddress(note.toNEvent(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.EVENT) { - val note = dao.getOrCreateNote(results.key.hex) - - getNostrAddress(note.toNEvent(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.ADDRESS) { - val note = dao.checkGetOrCreateAddressableNote(results.key.hex) - if (note != null) { - getNostrAddress(note.idNote(), results.restOfWord) - } else { + when (val entity = results?.key?.entity) { + is Nip19Bech32.NPub -> { + getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord) + } + is Nip19Bech32.NProfile -> { + getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord) + } + + is Nip19Bech32.Note -> { + getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord) + } + is Nip19Bech32.NEvent -> { + getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord) + } + + is Nip19Bech32.NAddress -> { + val note = dao.checkGetOrCreateAddressableNote(entity.atag) + if (note != null) { + getNostrAddress(note.idNote(), results.restOfWord) + } else { + word + } + } + + is Nip19Bech32.NEmbed -> { + word + } + + is Nip19Bech32.NSec -> { + word + } + + is Nip19Bech32.NRelay -> { + word + } + + else -> { word } - } else { - word } } .joinToString(" ") @@ -134,7 +159,7 @@ class NewMessageTagger( } } - @Immutable data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) + @Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String) fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { var key = mightBeAKey @@ -154,7 +179,7 @@ class NewMessageTagger( val restOfWord = key.substring(63) // Converts to npub val pubkey = - Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null + Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null return DirtyKeyInfo(pubkey, restOfWord) } else if (key.startsWith("npub1", true)) { @@ -165,7 +190,7 @@ class NewMessageTagger( val keyB32 = key.substring(0, 63) val restOfWord = key.substring(63) - val pubkey = Nip19.uriToRoute(keyB32) ?: return null + val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null return DirtyKeyInfo(pubkey, restOfWord) } else if (key.startsWith("note1", true)) { @@ -176,19 +201,19 @@ class NewMessageTagger( val keyB32 = key.substring(0, 63) val restOfWord = key.substring(63) - val noteId = Nip19.uriToRoute(keyB32) ?: return null + val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null return DirtyKeyInfo(noteId, restOfWord) } else if (key.startsWith("nprofile", true)) { - val pubkeyRelay = Nip19.uriToRoute(key) ?: return null + val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) } else if (key.startsWith("nevent1", true)) { - val noteRelayId = Nip19.uriToRoute(key) ?: return null + val noteRelayId = Nip19Bech32.uriToRoute(key) ?: return null return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) } else if (key.startsWith("naddr1", true)) { - val address = Nip19.uriToRoute(key) ?: return null + val address = Nip19Bech32.uriToRoute(key) ?: return null return DirtyKeyInfo( address, @@ -196,6 +221,7 @@ class NewMessageTagger( ) // no way to know when they address ends and dirt begins } } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt index 39f16194c..b17ade6d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.CancellationException @Composable fun NewPollClosing(pollViewModel: NewPostViewModel) { @@ -58,6 +59,7 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) { pollViewModel.closedAt = int } } catch (e: Exception) { + if (e is CancellationException) throw e pollViewModel.isValidClosedAt.value = false } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt index 271c0f346..d181c873d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.CancellationException @Composable fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) { @@ -58,6 +59,7 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) { pollViewModel.consensusThreshold = int } } catch (e: Exception) { + if (e is CancellationException) throw e pollViewModel.isValidConsensusThreshold.value = false } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index e078a506f..6f8378cc0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt index b969ba8bc..4c355c6b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt index a5c02bdcf..10cb12bc3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt index 8e6b85fe7..806eee50b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 9fa061371..abcb99045 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,6 +30,7 @@ import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,8 +50,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape @@ -98,7 +97,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester @@ -133,23 +131,18 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil -import com.vitorpamplona.amethyst.service.noProtocolUrlValidator -import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest -import com.vitorpamplona.amethyst.ui.components.imageExtensions -import com.vitorpamplona.amethyst.ui.components.isValidURL -import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison -import com.vitorpamplona.amethyst.ui.components.videoExtensions import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.CancelIcon import com.vitorpamplona.amethyst.ui.note.CloseIcon @@ -159,9 +152,9 @@ import com.vitorpamplona.amethyst.ui.note.RegularPostIcon import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer -import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -181,6 +174,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -194,6 +188,7 @@ fun NewPostView( onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, + fork: Note? = null, enableMessageInterface: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -212,13 +207,13 @@ fun NewPostView( launch(Dispatchers.IO) { val replyDraft = LocalPreferences.loadReplyDraft(accountViewModel.account) if (replyDraft.isNullOrBlank()) { - postViewModel.load(accountViewModel, baseReplyTo, quote) + postViewModel.load(accountViewModel, baseReplyTo, quote, fork) } else { val note = LocalCache.checkGetOrCreateNote(replyDraft) if (note == null) { - postViewModel.load(accountViewModel, baseReplyTo, quote) + postViewModel.load(accountViewModel, baseReplyTo, quote, fork) } else { - postViewModel.load(accountViewModel, note, quote) + postViewModel.load(accountViewModel, note, quote, fork) } } @@ -274,7 +269,7 @@ fun NewPostView( ) { Icon( painter = painterResource(R.drawable.relays), - contentDescription = null, + contentDescription = stringResource(id = R.string.relay_list_selector), modifier = Modifier.height(25.dp), tint = MaterialTheme.colorScheme.onBackground, ) @@ -315,25 +310,38 @@ fun NewPostView( ) { pad -> Surface( modifier = - Modifier.padding( - start = Size10dp, - top = pad.calculateTopPadding(), - end = Size10dp, - bottom = pad.calculateBottomPadding(), - ) + Modifier + .padding( + start = Size10dp, + top = pad.calculateTopPadding(), + end = Size10dp, + bottom = pad.calculateBottomPadding(), + ) .fillMaxSize(), ) { Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(), ) { Column( - modifier = Modifier.imePadding().weight(1f), + modifier = + Modifier + .imePadding() + .weight(1f), ) { Row( - modifier = Modifier.fillMaxWidth().weight(1f), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), ) { Column( - modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), ) { postViewModel.originalNote?.let { Row(Modifier.heightIn(max = 200.dp)) { @@ -385,6 +393,52 @@ fun NewPostView( } } + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + if (RichTextParser.isValidURL(myUrlPreview)) { + if (RichTextParser.isImageUrl(myUrlPreview)) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) + } else if (RichTextParser.isVideoUrl(myUrlPreview)) { + VideoView( + myUrlPreview, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } else { + LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + + BechLink( + myUrlPreview, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { + LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } + } + } + if (postViewModel.wantsToMarkAsSensitive) { Row( verticalAlignment = Alignment.CenterVertically, @@ -472,133 +526,85 @@ fun NewPostView( ) } } - - val myUrlPreview = postViewModel.urlPreview - if (myUrlPreview != null) { - Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - if (isValidURL(myUrlPreview)) { - val removedParamsFromUrl = - removeQueryParamsForExtensionComparison(myUrlPreview) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = myUrlPreview, - contentDescription = myUrlPreview, - contentScale = ContentScale.FillWidth, - modifier = - Modifier.padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - VideoView( - myUrlPreview, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } else { - LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) - } - } else if (startsWithNIP19Scheme(myUrlPreview)) { - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(bgColor) } - - BechLink( - myUrlPreview, - true, - backgroundColor, - accountViewModel, - nav, - ) - } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { - LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) - } - } - } - } - } - - val userSuggestions = postViewModel.userSuggestions - if (userSuggestions.isNotEmpty()) { - LazyColumn( - contentPadding = - PaddingValues( - top = 10.dp, - ), - modifier = Modifier.heightIn(0.dp, 300.dp), - ) { - itemsIndexed( - userSuggestions, - key = { _, item -> item.pubkeyHex }, - ) { _, item -> - UserLine(item, accountViewModel) { postViewModel.autocompleteWithUser(item) } - } } } - Row( - modifier = Modifier.fillMaxWidth().height(50.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UploadFromGallery( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, - ) { - postViewModel.selectImage(it) - } + ShowUserSuggestionList( + postViewModel, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) - if (postViewModel.canUsePoll) { - // These should be hashtag recommendations the user selects in the future. - // val hashtag = stringResource(R.string.poll_hashtag) - // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) - AddPollButton(postViewModel.wantsPoll) { - postViewModel.wantsPoll = !postViewModel.wantsPoll - if (postViewModel.wantsPoll) { - postViewModel.wantsProduct = false - } - } - } + BottomRowActions(postViewModel) + } + } + } + } + } +} - AddClassifiedsButton(postViewModel) { - postViewModel.wantsProduct = !postViewModel.wantsProduct - if (postViewModel.wantsProduct) { - postViewModel.wantsPoll = false - } - } +@Composable +private fun BottomRowActions(postViewModel: NewPostViewModel) { + val scrollState = rememberScrollState() - if (postViewModel.canAddInvoice) { - AddLnInvoiceButton(postViewModel.wantsInvoice) { - postViewModel.wantsInvoice = !postViewModel.wantsInvoice - } - } + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } - if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapraiser) { - postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser - } - } + if (postViewModel.canUsePoll) { + // These should be hashtag recommendations the user selects in the future. + // val hashtag = stringResource(R.string.poll_hashtag) + // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) + AddPollButton(postViewModel.wantsPoll) { + postViewModel.wantsPoll = !postViewModel.wantsPoll + if (postViewModel.wantsPoll) { + postViewModel.wantsProduct = false + } + } + } - MarkAsSensitive(postViewModel) { - postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive - } + AddClassifiedsButton(postViewModel) { + postViewModel.wantsProduct = !postViewModel.wantsProduct + if (postViewModel.wantsProduct) { + postViewModel.wantsPoll = false + } + } - AddGeoHash(postViewModel) { - postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash - } + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } - ForwardZapTo(postViewModel) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo - } - } - } - } + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser } } + + MarkAsSensitive(postViewModel) { + postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive + } + + AddGeoHash(postViewModel) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } } } @@ -634,7 +640,6 @@ private fun PollField(postViewModel: NewPostViewModel) { } } -@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable private fun MessageField(postViewModel: NewPostViewModel) { val focusRequester = remember { FocusRequester() } @@ -655,7 +660,8 @@ private fun MessageField(postViewModel: NewPostViewModel) { capitalization = KeyboardCapitalization.Sentences, ), modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .border( width = 1.dp, color = MaterialTheme.colorScheme.surface, @@ -695,21 +701,32 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) { ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), ) { Box( - Modifier.height(20.dp).width(25.dp), + Modifier + .height(20.dp) + .width(25.dp), ) { Icon( imageVector = Icons.Default.VisibilityOff, contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + modifier = + Modifier + .size(18.dp) + .align(Alignment.BottomStart), tint = Color.Red, ) Icon( imageVector = Icons.Rounded.Warning, contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + modifier = + Modifier + .size(10.dp) + .align(Alignment.TopEnd), tint = Color.Yellow, ) } @@ -932,7 +949,10 @@ fun SellProduct(postViewModel: NewPostViewModel) { placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, options = conditionOptions, onSelect = { postViewModel.condition = conditionTypes[it].first }, - modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), + modifier = + Modifier + .weight(1f) + .padding(end = 5.dp, bottom = 1.dp), ) { currentOption, modifier -> MyTextField( value = TextFieldValue(currentOption), @@ -993,7 +1013,10 @@ fun SellProduct(postViewModel: NewPostViewModel) { ?: "", options = categoryOptions, onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) }, - modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), + modifier = + Modifier + .weight(1f) + .padding(end = 5.dp, bottom = 1.dp), ) { currentOption, modifier -> MyTextField( value = TextFieldValue(currentOption), @@ -1058,21 +1081,32 @@ fun FowardZapTo( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), ) { Box( - Modifier.height(20.dp).width(25.dp), + Modifier + .height(20.dp) + .width(25.dp), ) { Icon( imageVector = Icons.Outlined.Bolt, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + modifier = + Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = BitcoinOrange, ) Icon( imageVector = Icons.Outlined.ArrowForwardIos, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + modifier = + Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = BitcoinOrange, ) } @@ -1172,10 +1206,15 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), ) { Box( - Modifier.height(20.dp).width(20.dp), + Modifier + .height(20.dp) + .width(20.dp), ) { Icon( imageVector = Icons.Default.LocationOn, @@ -1316,32 +1355,46 @@ private fun AddZapraiserButton( onClick = { onClick() }, ) { Box( - Modifier.height(20.dp).width(25.dp), + Modifier + .height(20.dp) + .width(25.dp), ) { if (!isLnInvoiceActive) { Icon( imageVector = Icons.Default.ShowChart, null, - modifier = Modifier.size(20.dp).align(Alignment.TopStart), + modifier = + Modifier + .size(20.dp) + .align(Alignment.TopStart), tint = MaterialTheme.colorScheme.onBackground, ) Icon( imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + contentDescription = stringResource(R.string.add_zapraiser), + modifier = + Modifier + .size(13.dp) + .align(Alignment.BottomEnd), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Default.ShowChart, null, - modifier = Modifier.size(20.dp).align(Alignment.TopStart), + modifier = + Modifier + .size(20.dp) + .align(Alignment.TopStart), tint = BitcoinOrange, ) Icon( imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + contentDescription = stringResource(R.string.cancel_zapraiser), + modifier = + Modifier + .size(13.dp) + .align(Alignment.BottomEnd), tint = BitcoinOrange, ) } @@ -1360,14 +1413,14 @@ fun AddGeoHash( if (!postViewModel.wantsToAddGeoHash) { Icon( imageVector = Icons.Default.LocationOff, - null, + contentDescription = stringResource(id = R.string.add_location), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Default.LocationOn, - null, + contentDescription = stringResource(id = R.string.remove_location), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -1386,14 +1439,14 @@ private fun AddLnInvoiceButton( if (!isLnInvoiceActive) { Icon( imageVector = Icons.Default.CurrencyBitcoin, - null, + contentDescription = stringResource(id = R.string.add_bitcoin_invoice), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Default.CurrencyBitcoin, - null, + contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice), modifier = Modifier.size(20.dp), tint = BitcoinOrange, ) @@ -1410,32 +1463,46 @@ private fun ForwardZapTo( onClick = { onClick() }, ) { Box( - Modifier.height(20.dp).width(25.dp), + Modifier + .height(20.dp) + .width(25.dp), ) { if (!postViewModel.wantsForwardZapTo) { Icon( imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + contentDescription = stringResource(R.string.add_zap_split), + modifier = + Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = MaterialTheme.colorScheme.onBackground, ) Icon( imageVector = Icons.Default.ArrowForwardIos, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + contentDescription = null, + modifier = + Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + contentDescription = stringResource(id = R.string.cancel_zap_split), + modifier = + Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = BitcoinOrange, ) Icon( imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + contentDescription = null, + modifier = + Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = BitcoinOrange, ) } @@ -1461,7 +1528,7 @@ private fun AddClassifiedsButton( } else { Icon( imageVector = Icons.Default.Sell, - contentDescription = stringResource(id = R.string.classifieds), + contentDescription = stringResource(id = R.string.cancel_classifieds), modifier = Modifier.size(20.dp), tint = BitcoinOrange, ) @@ -1478,32 +1545,46 @@ private fun MarkAsSensitive( onClick = { onClick() }, ) { Box( - Modifier.height(20.dp).width(23.dp), + Modifier + .height(20.dp) + .width(23.dp), ) { if (!postViewModel.wantsToMarkAsSensitive) { Icon( imageVector = Icons.Default.Visibility, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + contentDescription = stringResource(R.string.add_content_warning), + modifier = + Modifier + .size(18.dp) + .align(Alignment.BottomStart), tint = MaterialTheme.colorScheme.onBackground, ) Icon( imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + contentDescription = null, + modifier = + Modifier + .size(10.dp) + .align(Alignment.TopEnd), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Default.VisibilityOff, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + contentDescription = stringResource(id = R.string.remove_content_warning), + modifier = + Modifier + .size(18.dp) + .align(Alignment.BottomStart), tint = Color.Red, ) Icon( imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + contentDescription = null, + modifier = + Modifier + .size(10.dp) + .align(Alignment.TopEnd), tint = Color.Yellow, ) } @@ -1612,7 +1693,8 @@ fun ImageVideoDescription( Column( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(start = 30.dp, end = 30.dp) .clip(shape = QuoteBorder) .border( @@ -1622,11 +1704,17 @@ fun ImageVideoDescription( ), ) { Column( - modifier = Modifier.fillMaxWidth().padding(30.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(30.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), ) { Text( text = @@ -1644,13 +1732,17 @@ fun ImageVideoDescription( fontSize = 20.sp, fontWeight = FontWeight.W500, modifier = - Modifier.padding(start = 10.dp) + Modifier + .padding(start = 10.dp) .weight(1.0f) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), ) IconButton( - modifier = Modifier.size(30.dp).padding(end = 5.dp), + modifier = + Modifier + .size(30.dp) + .padding(end = 5.dp), onClick = onCancel, ) { CancelIcon() @@ -1662,7 +1754,8 @@ fun ImageVideoDescription( Row( verticalAlignment = Alignment.CenterVertically, modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(bottom = 10.dp) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), ) { @@ -1672,7 +1765,8 @@ fun ImageVideoDescription( contentDescription = uri.toString(), contentScale = ContentScale.FillWidth, modifier = - Modifier.padding(top = 4.dp) + Modifier + .padding(top = 4.dp) .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), ) @@ -1686,6 +1780,7 @@ fun ImageVideoDescription( try { bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) } catch (e: Exception) { + if (e is CancellationException) throw e onError("Unable to load thumbnail") Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) } @@ -1697,7 +1792,10 @@ fun ImageVideoDescription( bitmap = it.asImageBitmap(), contentDescription = "some useful description", contentScale = ContentScale.FillWidth, - modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), + modifier = + Modifier + .padding(top = 4.dp) + .fillMaxWidth(), ) } } else { @@ -1719,7 +1817,10 @@ fun ImageVideoDescription( ?: fileServers[0].server.name, options = fileServerOptions, onSelect = { selectedServer = fileServers[it] }, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), + modifier = + Modifier + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .weight(1f), ) } @@ -1728,6 +1829,10 @@ fun ImageVideoDescription( modifier = Modifier.fillMaxWidth(), ) { SettingSwitchItem( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), checked = sensitiveContent, onCheckedChange = { sensitiveContent = it }, title = R.string.add_sensitive_content_label, @@ -1738,12 +1843,16 @@ fun ImageVideoDescription( Row( verticalAlignment = Alignment.CenterVertically, modifier = - Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), ) { OutlinedTextField( label = { Text(text = stringResource(R.string.content_description)) }, modifier = - Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, onValueChange = { message = it }, placeholder = { @@ -1760,7 +1869,10 @@ fun ImageVideoDescription( } Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), onClick = { onAdd(message, selectedServer, sensitiveContent) }, shape = QuoteBorder, colors = @@ -1776,7 +1888,10 @@ fun ImageVideoDescription( @Composable fun SettingSwitchItem( - modifier: Modifier = Modifier, + modifier: Modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), checked: Boolean, onCheckedChange: (Boolean) -> Unit, title: Int, @@ -1786,8 +1901,6 @@ fun SettingSwitchItem( Row( modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) .toggleable( value = checked, enabled = enabled, @@ -1798,7 +1911,7 @@ fun SettingSwitchItem( ) { Column( modifier = Modifier.weight(1.0f), - verticalArrangement = Arrangement.spacedBy(3.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), ) { Text( text = stringResource(id = title), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 17482fd0a..1a06d7768 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -36,6 +36,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.insertUrlAtCursor import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -44,11 +46,9 @@ import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.LocationUtil import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.Split -import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.AddressableEvent @@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent @@ -64,6 +65,7 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.findURLs +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow @@ -71,7 +73,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch -import java.net.URLEncoder enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -86,6 +87,7 @@ open class NewPostViewModel() : ViewModel() { var requiresNIP24: Boolean = false var originalNote: Note? = null + var forkedFromNote: Note? = null var pTags by mutableStateOf?>(null) var eTags by mutableStateOf?>(null) @@ -166,6 +168,7 @@ open class NewPostViewModel() : ViewModel() { accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?, + fork: Note?, ) { this.accountViewModel = accountViewModel this.account = accountViewModel.account @@ -197,11 +200,6 @@ open class NewPostViewModel() : ViewModel() { pTags = null } - quote?.let { - message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") - urlPreview = findUrlInMessage() - } - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null @@ -222,6 +220,72 @@ open class NewPostViewModel() : ViewModel() { updateMessage(message) } } + quote?.let { + message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() + + it.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { + forwardZapTo.addItem(quotedUser) + } + if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } + forwardZapTo.updatePercentage(pos, 0.9f) + } + } + } + + fork?.let { + message = TextFieldValue(it.event?.content() ?: "") + urlPreview = findUrlInMessage() + + it.event?.isSensitive()?.let { + if (it) wantsToMarkAsSensitive = true + } + + it.event?.zapraiserAmount()?.let { + zapRaiserAmount = it + } + + it.event?.zapSplitSetup()?.let { + val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight } + + it.forEach { + if (!it.isLnAddress) { + forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat()) + } + } + } + + // Only adds if it is not already set up. + if (forwardZapTo.items.isEmpty()) { + it.author?.let { forkedAuthor -> + if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor) + if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile()) + + val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex } + forwardZapTo.updatePercentage(pos, 0.8f) + } + } + } + + it.author?.let { + if (this.pTags?.contains(it) != true) { + this.pTags = listOf(it) + (this.pTags ?: emptyList()) + } + } + + forkedFromNote = it + } + + if (!forwardZapTo.items.isEmpty()) { + wantsForwardZapTo = true + } } fun sendPost(relayList: List? = null) { @@ -273,45 +337,47 @@ open class NewPostViewModel() : ViewModel() { } val urls = findURLs(tagger.message) - val usedAttachments = nip94attachments.filter { it.urls().intersect(urls).isNotEmpty() } - usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } + val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + // Doesn't send as nip94 yet because we don't know if it makes sense. + // usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } if (originalNote?.channelHex() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) { account?.sendLiveMessage( - tagger.message, - originalNote?.address()!!, - tagger.eTags, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, + message = tagger.message, + toChannel = originalNote?.address()!!, + replyTo = tagger.eTags, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, nip94attachments = usedAttachments, ) } else { account?.sendChannelMessage( - tagger.message, - tagger.channelHex!!, - tagger.eTags, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, + message = tagger.message, + toChannel = tagger.channelHex!!, + replyTo = tagger.eTags, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, nip94attachments = usedAttachments, ) } } else if (originalNote?.event is PrivateDmEvent) { account?.sendPrivateMessage( - tagger.message, - originalNote!!.author!!, - originalNote!!, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, + message = tagger.message, + toUser = originalNote!!.author!!, + replyingTo = originalNote!!, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + nip94attachments = usedAttachments, ) } else if (originalNote?.event is ChatMessageEvent) { val receivers = @@ -332,6 +398,7 @@ open class NewPostViewModel() : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, + nip94attachments = usedAttachments, ) } else if (!dmUsers.isNullOrEmpty()) { if (nip24 || dmUsers.size > 1) { @@ -345,6 +412,7 @@ open class NewPostViewModel() : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, + nip94attachments = usedAttachments, ) } else { account?.sendPrivateMessage( @@ -356,6 +424,7 @@ open class NewPostViewModel() : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, + nip94attachments = usedAttachments, ) } } else { @@ -407,9 +476,16 @@ open class NewPostViewModel() : ViewModel() { val replyId = originalNote?.idHex + val replyToSet = + if (forkedFromNote != null) { + (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } + } else { + tagger.eTags + } + account?.sendPost( message = tagger.message, - replyTo = tagger.eTags, + replyTo = replyToSet, mentions = tagger.pTags, tags = null, zapReceiver = zapReceiver, @@ -418,6 +494,7 @@ open class NewPostViewModel() : ViewModel() { replyingTo = replyId, root = rootId, directMentions = tagger.directMentions, + forkedFrom = forkedFromNote?.event as? Event, relayList = relayList, geohash = geoHash, nip94attachments = usedAttachments, @@ -469,22 +546,14 @@ open class NewPostViewModel() : ViewModel() { onProgress = {}, ) - if (!isPrivate) { - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - ) - } else { - noNIP94( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - ) - } + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e( "ImageUploader", "Failed to upload ${e.message}", @@ -556,7 +625,7 @@ open class NewPostViewModel() : ViewModel() { open fun findUrlInMessage(): String? { return message.text.split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> - isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) } } } @@ -736,21 +805,6 @@ open class NewPostViewModel() : ViewModel() { contentToAddUrl == null } - fun includePollHashtagInMessage( - include: Boolean, - hashtag: String, - ) { - if (include) { - updateMessage(TextFieldValue(message.text + " $hashtag")) - } else { - updateMessage( - TextFieldValue( - message.text.replace(" $hashtag", "").replace(hashtag, ""), - ), - ) - } - } - suspend fun createNIP94Record( uploadingResult: Nip96Uploader.PartialEvent, localContentType: String?, @@ -784,81 +838,12 @@ open class NewPostViewModel() : ViewModel() { mimeType = remoteMimeType ?: localContentType, dimPrecomputed = dim, onReady = { header: FileHeader -> - account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { - event, - -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> isUploadingImage = false nip94attachments = nip94attachments + event - val contentWarning = if (sensitiveContent) "" else null - message = - TextFieldValue( - message.text + - "\n" + - addInlineMetadataAsNIP54( - imageUrl, - header.dim, - header.mimeType, - alt, - header.blurHash, - header.hash, - contentWarning, - ), - ) - urlPreview = findUrlInMessage() - } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } - }, - ) - } - suspend fun noNIP94( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String?, - sensitiveContent: Boolean, - ) { - // Images don't seem to be ready immediately after upload - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } - val dim = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Server failed to return a url") } - return - } - - FileHeader.prepare( - fileUrl = imageUrl, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { header: FileHeader -> - isUploadingImage = false - val contentWarning = if (sensitiveContent) "" else null - message = - TextFieldValue( - message.text + - "\n" + - addInlineMetadataAsNIP54( - imageUrl, - header.dim, - header.mimeType, - alt, - header.blurHash, - header.hash, - contentWarning, - ), - ) - urlPreview = findUrlInMessage() - viewModelScope.launch(Dispatchers.IO) { - saveDraft(message.text) + message = message.insertUrlAtCursor(imageUrl) + urlPreview = findUrlInMessage() } }, onError = { @@ -868,35 +853,6 @@ open class NewPostViewModel() : ViewModel() { ) } - fun addInlineMetadataAsNIP54( - imageUrl: String, - dim: String?, - m: String?, - alt: String?, - blurHash: String?, - x: String?, - sensitiveContent: String?, - ): String { - val extension = - listOfNotNull( - m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" }, - dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" }, - alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" }, - blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" }, - x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" }, - sensitiveContent - ?.ifBlank { null } - ?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }, - ) - .joinToString("&") - - return if (imageUrl.contains("#")) { - "$imageUrl&$extension" - } else { - "$imageUrl#$extension" - } - } - fun createNIP95Record( bytes: ByteArray, mimeType: String?, @@ -924,7 +880,7 @@ open class NewPostViewModel() : ViewModel() { isUploadingImage = false note?.let { - message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) saveDraft(message.text) } @@ -985,6 +941,7 @@ open class NewPostViewModel() : ViewModel() { valueMinimum = int } } catch (e: Exception) { + if (e is CancellationException) throw e } } else { valueMinimum = null @@ -1003,6 +960,7 @@ open class NewPostViewModel() : ViewModel() { valueMaximum = int } } catch (e: Exception) { + if (e is CancellationException) throw e } } else { valueMaximum = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 53449fc1c..2e809ae02 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -79,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.RelayBriefInfoCache import com.vitorpamplona.amethyst.model.RelaySetupInfo +import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays @@ -193,8 +194,10 @@ fun NewRelayListView( onToggleSearch = { postViewModel.toggleSearch(it) }, onDelete = { postViewModel.deleteRelay(it) }, accountViewModel = accountViewModel, - nav = nav, - ) + ) { + onClose() + nav(it) + } } } } @@ -410,9 +413,14 @@ fun ServerConfigClickableLine( modifier = Modifier.padding(vertical = 5.dp), ) { Column(Modifier.clickable(onClick = onClick)) { + val iconUrlFromRelayInfoDoc = + remember(item) { + Nip11CachedRetriever.getFromCache(item.url)?.icon + } + RenderRelayIcon( item.briefInfo.displayUrl, - item.briefInfo.favIcon, + iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon, loadProfilePicture, MaterialTheme.colorScheme.largeRelayIconModifier, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index 7718c72ca..a86574f67 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -62,7 +62,9 @@ class NewRelayListViewModel : ViewModel() { _relays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( dirtyUrl = item.url, - onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, + onInfo = { + togglePaidRelay(item, it.limitation?.payment_required ?: false) + }, onError = { url, errorCode, exceptionMessage -> }, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index 8ff81dfdc..dd0446d20 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 1824dbbc5..d0af1b125 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -37,6 +37,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException class NewUserMetadataViewModel : ViewModel() { private lateinit var account: Account @@ -197,6 +198,7 @@ class NewUserMetadataViewModel : ViewModel() { } } } catch (e: Exception) { + if (e is CancellationException) throw e onUploading(false) viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt index d5ff4f92e..c19d7531c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt index 96f45a127..464d7ec82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -48,7 +48,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.RelayBriefInfoCache -import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.amethyst.ui.components.ClickableEmail import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.note.LoadUser @@ -59,13 +58,14 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier +import com.vitorpamplona.quartz.encoders.Nip11RelayInformation @OptIn(ExperimentalLayoutApi::class) @Composable fun RelayInformationDialog( onClose: () -> Unit, relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - relayInfo: RelayInformation, + relayInfo: Nip11RelayInformation, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -104,7 +104,7 @@ fun RelayInformationDialog( Column { RenderRelayIcon( relayBriefInfo.displayUrl, - relayBriefInfo.favIcon, + relayInfo.icon ?: relayBriefInfo.favIcon, automaticallyShowProfilePicture, MaterialTheme.colorScheme.largeRelayIconModifier, ) @@ -121,7 +121,12 @@ fun RelayInformationDialog( Section(stringResource(R.string.owner)) - relayInfo.pubkey?.let { DisplayOwnerInformation(it, accountViewModel, nav) } + relayInfo.pubkey?.let { + DisplayOwnerInformation(it, accountViewModel) { + onClose() + nav(it) + } + } Section(stringResource(R.string.software)) @@ -170,12 +175,14 @@ fun RelayInformationDialog( relayInfo.limitation?.let { Section(stringResource(R.string.limitations)) - val authRequired = it.auth_required ?: false val authRequiredText = - if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no) - val paymentRequired = it.payment_required ?: false + if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no) + val paymentRequiredText = - if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no) + if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no) + + val restrictedWritesText = + if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no) Column { SectionContent( @@ -184,7 +191,7 @@ fun RelayInformationDialog( SectionContent( "${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", ) - SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}") + SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}") SectionContent( "${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", ) @@ -195,9 +202,13 @@ fun RelayInformationDialog( SectionContent( "${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}", ) + SectionContent( + "${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}", + ) SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") SectionContent("${stringResource(R.string.auth)}: $authRequiredText") SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText") + SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText") } } @@ -236,7 +247,7 @@ fun RelayInformationDialog( @Composable @OptIn(ExperimentalLayoutApi::class) -private fun DisplaySupportedNips(relayInfo: RelayInformation) { +private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) { FlowRow { relayInfo.supported_nips?.forEach { item -> val text = item.toString().padStart(2, '0') @@ -261,7 +272,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) { } @Composable -private fun DisplaySoftwareInformation(relayInfo: RelayInformation) { +private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) { val url = (relayInfo.software ?: "").replace("git+", "") Box(modifier = Modifier.padding(start = 10.dp)) { ClickableUrl( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt index a82544c26..b64c63cec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -47,11 +47,11 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.RelayBriefInfoCache -import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.encoders.Nip11RelayInformation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -63,7 +63,7 @@ data class RelayList( data class RelayInfoDialog( val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - val relayInfo: RelayInformation, + val relayInfo: Nip11RelayInformation, ) @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index ad2ad7830..10e8ec0db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index 89a9e98be..0384ca652 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 32218b39a..de8140fbe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -33,6 +33,7 @@ import androidx.compose.ui.text.style.TextDecoration import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.encoders.decodePublicKey import com.vitorpamplona.quartz.encoders.toHexKey +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt data class RangesChanges(val original: TextRange, val modified: TextRange) @@ -55,11 +56,9 @@ fun buildAnnotatedStringWithUrlHighlighting( val builderAfter = StringBuilder() // important to correctly measure Tag start and end append( text - .split('\n') - .map { paragraph: String -> + .split('\n').joinToString("\n") { paragraph: String -> paragraph - .split(' ') - .map { word: String -> + .split(' ').joinToString(" ") { word: String -> try { if (word.startsWith("@npub") && word.length >= 64) { val keyB32 = word.substring(0, 64) @@ -113,15 +112,14 @@ fun buildAnnotatedStringWithUrlHighlighting( word } } catch (e: Exception) { + if (e is CancellationException) throw e // if it can't parse the key, don't try to change. builderBefore.append("$word ") builderAfter.append("$word ") word } } - .joinToString(" ") - } - .joinToString("\n"), + }, ) substitutions.forEach { @@ -141,9 +139,7 @@ fun buildAnnotatedStringWithUrlHighlighting( object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { val inInsideRange = - substitutions - .filter { offset > it.original.start && offset < it.original.end } - .firstOrNull() + substitutions.firstOrNull { offset > it.original.start && offset < it.original.end } if (inInsideRange != null) { val percentInRange = @@ -163,9 +159,7 @@ fun buildAnnotatedStringWithUrlHighlighting( override fun transformedToOriginal(offset: Int): Int { val inInsideRange = - substitutions - .filter { offset > it.modified.start && offset < it.modified.end } - .firstOrNull() + substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end } if (inInsideRange != null) { val percentInRange = diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt index a9a386ca2..018d32834 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -65,7 +65,7 @@ fun ChannelFabColumn( if (wantsToSendNewMessage) { NewPostView( - { wantsToSendNewMessage = false }, + onClose = { wantsToSendNewMessage = false }, enableMessageInterface = true, accountViewModel = accountViewModel, nav = nav, @@ -123,7 +123,7 @@ fun ChannelFabColumn( ) { Icon( imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.messages_create_public_chat), + contentDescription = stringResource(R.string.messages_create_public_private_chat_desription), modifier = Modifier.size(26.dp), tint = Color.White, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt index 0b92bd7fa..2ca79c684 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt index 39d724aa4..63f132eeb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -72,7 +73,7 @@ fun NewCommunityNoteButton( ) { Icon( painter = painterResource(R.drawable.ic_compose), - null, + contentDescription = stringResource(id = R.string.new_community_note), modifier = Modifier.size(26.dp), tint = Color.White, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt index 6ef527715..16973d1c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -135,7 +136,7 @@ fun NewImageButton( ) { Icon( painter = painterResource(R.drawable.ic_compose), - null, + contentDescription = stringResource(id = R.string.new_short), modifier = Modifier.size(26.dp), tint = Color.White, ) @@ -153,7 +154,10 @@ private fun ShowProgress(postViewModel: NewMediaModel) { animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, ) .value, - modifier = Size55Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.background), + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), strokeWidth = 5.dp, ) postViewModel.uploadingDescription.value?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt index d128bed2e..a0e8fe440 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.NewPostView @@ -58,7 +59,7 @@ fun NewNoteButton( ) { Icon( painter = painterResource(R.drawable.ic_compose), - null, + contentDescription = stringResource(R.string.new_post), modifier = Modifier.size(26.dp), tint = Color.White, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index 9ad036ff6..7002a976e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index eec719159..be77cf5f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index d71e64e82..c77dd6782 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.SmallishBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -238,6 +239,7 @@ fun CashuPreview( startActivity(context, intent, null) } catch (e: Exception) { + if (e is CancellationException) throw e toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) } }, @@ -354,6 +356,7 @@ fun CashuPreviewNew( startActivity(context, intent, null) } catch (e: Exception) { + if (e is CancellationException) throw e toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) } }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index 7faddad2f..2ad3d1f64 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString +import kotlinx.coroutines.CancellationException @Composable fun ClickableEmail(email: String) { @@ -58,6 +59,7 @@ fun Context.sendMail( } catch (e: ActivityNotFoundException) { // TODO: Handle case where no email app is available } catch (t: Throwable) { + if (t is CancellationException) throw t // TODO: Handle potential other type of exceptions } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt index 92ee3fea5..93e279e1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index e52aa1c3d..990ee9137 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index dab4b350c..f5743f460 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,8 +23,6 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.LocalContentColor @@ -32,7 +30,6 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -61,63 +58,79 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.Nip30CustomEmoji import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.events.ChannelCreateEvent +import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun ClickableRoute( - nip19: Nip19.Return, + word: String, + nip19: Nip19Bech32.ParseReturn, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - when (nip19.type) { - Nip19.Type.USER -> { - DisplayUser(nip19, accountViewModel, nav) + when (val entity = nip19.entity) { + is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav) + is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav) + is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.additionalChars, accountViewModel, nav) + is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.additionalChars, accountViewModel, nav) + is Nip19Bech32.NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav) + is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.additionalChars, accountViewModel, nav) + is Nip19Bech32.NRelay -> { + Text(word) } - Nip19.Type.ADDRESS -> { - DisplayAddress(nip19, accountViewModel, nav) - } - Nip19.Type.NOTE -> { - DisplayNote(nip19, accountViewModel, nav) - } - Nip19.Type.EVENT -> { - DisplayEvent(nip19, accountViewModel, nav) + is Nip19Bech32.NSec -> { + Text(word) } else -> { - Text( - remember { "@${nip19.hex}${nip19.additionalChars}" }, - ) + Text(word) } } } @Composable -private fun DisplayEvent( - nip19: Nip19.Return, +fun LoadOrCreateNote( + event: Event, + accountViewModel: AccountViewModel, + content: @Composable (Note?) -> Unit, +) { + var note by + remember(event.id) { mutableStateOf(accountViewModel.getNoteIfExists(event.id)) } + + if (note == null) { + LaunchedEffect(key1 = event.id) { + accountViewModel.checkGetOrCreateNote(event) { note = it } + } + } + + content(note) +} + +@Composable +private fun LoadAndDisplayEvent( + event: Event, + additionalChars: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - LoadNote(nip19.hex, accountViewModel) { + LoadOrCreateNote(event, accountViewModel) { if (it != null) { - DisplayNoteLink(it, nip19, accountViewModel, nav) + DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav) } else { CreateClickableText( - clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, - suffix = nip19.additionalChars, - route = remember(nip19) { "Event/${nip19.hex}" }, + clickablePart = remember(event.id) { "@${event.toNIP19()}" }, + suffix = additionalChars, + route = remember(event.id) { "Event/${event.id}" }, nav = nav, ) } @@ -125,19 +138,21 @@ private fun DisplayEvent( } @Composable -private fun DisplayNote( - nip19: Nip19.Return, +private fun DisplayEvent( + hex: HexKey, + kind: Int?, + additionalChars: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - LoadNote(nip19.hex, accountViewModel = accountViewModel) { + LoadNote(hex, accountViewModel) { if (it != null) { - DisplayNoteLink(it, nip19, accountViewModel, nav) + DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav) } else { CreateClickableText( - clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, - suffix = nip19.additionalChars, - route = remember(nip19) { "Event/${nip19.hex}" }, + clickablePart = remember(hex) { "@${hex.toShortenHex()}" }, + suffix = additionalChars, + route = remember(hex) { "Event/$hex" }, nav = nav, ) } @@ -147,7 +162,9 @@ private fun DisplayNote( @Composable private fun DisplayNoteLink( it: Note, - nip19: Nip19.Return, + hex: HexKey, + kind: Int?, + addedCharts: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -157,21 +174,20 @@ private fun DisplayNoteLink( val channelHex = remember(noteState) { note.channelHex() } val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } - if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) { + if (note.event is ChannelCreateEvent || kind == ChannelCreateEvent.KIND) { CreateClickableText( clickablePart = noteIdDisplayNote, suffix = addedCharts, - route = remember(noteState) { "Channel/${nip19.hex}" }, + route = remember(noteState) { "Channel/$hex" }, nav = nav, ) - } else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.KIND) { + } else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) { CreateClickableText( clickablePart = noteIdDisplayNote, suffix = addedCharts, route = - remember(noteState) { (note.author?.pubkeyHex ?: nip19.hex).let { "RoomByAuthor/$it" } }, + remember(noteState) { (note.author?.pubkeyHex ?: hex).let { "RoomByAuthor/$it" } }, nav = nav, ) } else if (channelHex != null) { @@ -193,7 +209,7 @@ private fun DisplayNoteLink( CreateClickableText( clickablePart = noteIdDisplayNote, suffix = addedCharts, - route = remember(noteState) { "Event/${nip19.hex}" }, + route = remember(noteState) { "Event/$hex" }, nav = nav, ) } @@ -201,28 +217,28 @@ private fun DisplayNoteLink( @Composable private fun DisplayAddress( - nip19: Nip19.Return, + nip19: Nip19Bech32.NAddress, + additionalChars: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.atag)) } if (noteBase == null) { - LaunchedEffect(key1 = nip19.hex) { - accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { noteBase = it } + LaunchedEffect(key1 = nip19.atag) { + accountViewModel.checkGetOrCreateAddressableNote(nip19.atag) { noteBase = it } } } noteBase?.let { val noteState by it.live().metadata.observeAsState() - val route = remember(noteState) { "Note/${nip19.hex}" } + val route = remember(noteState) { "Note/${nip19.atag}" } val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } CreateClickableText( clickablePart = displayName, - suffix = addedCharts, + suffix = additionalChars, route = route, nav = nav, ) @@ -230,35 +246,36 @@ private fun DisplayAddress( if (noteBase == null) { Text( - remember { "@${nip19.hex}${nip19.additionalChars}" }, + remember { "@${nip19.atag}$additionalChars" }, ) } } @Composable private fun DisplayUser( - nip19: Nip19.Return, + userHex: HexKey, + additionalChars: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { var userBase by - remember(nip19) { + remember(userHex) { mutableStateOf( - accountViewModel.getUserIfExists(nip19.hex), + accountViewModel.getUserIfExists(userHex), ) } if (userBase == null) { - LaunchedEffect(key1 = nip19.hex) { - accountViewModel.checkGetOrCreateUser(nip19.hex) { userBase = it } + LaunchedEffect(key1 = userHex) { + accountViewModel.checkGetOrCreateUser(userHex) { userBase = it } } } - userBase?.let { RenderUserAsClickableText(it, nip19, nav) } + userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) } if (userBase == null) { Text( - remember { "@${nip19.hex}${nip19.additionalChars}" }, + remember { "@${userHex}$additionalChars" }, ) } } @@ -266,7 +283,7 @@ private fun DisplayUser( @Composable private fun RenderUserAsClickableText( baseUser: User, - nip19: Nip19.Return, + additionalChars: String, nav: (String) -> Unit, ) { val userState by baseUser.live().metadata.observeAsState() @@ -280,17 +297,18 @@ private fun RenderUserAsClickableText( derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } } - val addedCharts = remember(nip19) { "${nip19.additionalChars}" } - userDisplayName?.let { CreateClickableTextWithEmoji( clickablePart = it, - suffix = addedCharts, maxLines = 1, route = route, nav = nav, tags = userTags, ) + + additionalChars.ifBlank { null }?.let { + Text(text = it, maxLines = 1) + } } } @@ -300,30 +318,30 @@ fun CreateClickableText( suffix: String?, maxLines: Int = Int.MAX_VALUE, overrideColor: Color? = null, - fontWeight: FontWeight = FontWeight.Normal, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, route: String, nav: (String) -> Unit, ) { - val currentStyle = LocalTextStyle.current val primaryColor = MaterialTheme.colorScheme.primary val onBackgroundColor = MaterialTheme.colorScheme.onBackground - val clickablePartStyle = - remember(primaryColor, overrideColor) { - currentStyle - .copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight) - .toSpanStyle() - } + val text = + remember(clickablePart, suffix) { + val clickablePartStyle = + SpanStyle( + fontSize = fontSize, + color = overrideColor ?: primaryColor, + fontWeight = fontWeight, + ) - val nonClickablePartStyle = - remember(onBackgroundColor, overrideColor) { - currentStyle - .copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight) - .toSpanStyle() - } + val nonClickablePartStyle = + SpanStyle( + fontSize = fontSize, + color = overrideColor ?: onBackgroundColor, + fontWeight = fontWeight, + ) - val text = - remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) { buildAnnotatedString { withStyle(clickablePartStyle) { append(clickablePart) } if (!suffix.isNullOrBlank()) { @@ -340,69 +358,112 @@ fun CreateClickableText( } @Composable -fun CreateTextWithEmoji( - text: String, - tags: ImmutableListOfLists?, - color: Color = Color.Unspecified, - textAlign: TextAlign? = null, - fontWeight: FontWeight? = null, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, +fun ClickableText( + text: AnnotatedString, modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + onClick: (Int) -> Unit, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = + Modifier.pointerInput(onClick) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> + onClick(layoutResult.getOffsetForPosition(pos)) } } } + + Text( + text = text, + modifier = modifier.then(pressIndicator), + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = { + layoutResult.value = it + onTextLayout(it) + }, + ) +} + +@Composable +fun CustomEmojiChecker( + text: String, + tags: ImmutableListOfLists?, + onRegularText: @Composable (String) -> Unit, + onEmojiText: @Composable (ImmutableList) -> Unit, +) { + val mayContainEmoji by remember(text, tags) { + mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, tags)) } - val textColor = - color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } + if (mayContainEmoji) { + var emojiList by + remember(text, tags) { + mutableStateOf?>(null) + } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) + LaunchedEffect(text, tags) { + val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, tags) + if (newEmojiList != null) { + emojiList = newEmojiList + } + } + + emojiList?.let { + onEmojiText(it) + } ?: run { + onRegularText(text) + } } else { - val style = - LocalTextStyle.current - .merge( - TextStyle( - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - ), - ) - .toSpanStyle() + onRegularText(text) + } +} + +@Composable +fun CustomEmojiChecker( + text: String, + emojis: ImmutableMap, + onRegularText: @Composable (String) -> Unit, + onEmojiText: @Composable (ImmutableList) -> Unit, +) { + val mayContainEmoji by remember(text, emojis) { + mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, emojis)) + } - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) + if (mayContainEmoji) { + var emojiList by + remember(text, emojis) { + mutableStateOf?>(null) + } + + LaunchedEffect(text, emojis) { + val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, emojis) + if (newEmojiList != null) { + emojiList = newEmojiList + } + } + + emojiList?.let { + onEmojiText(it) + } ?: run { + onRegularText(text) + } + } else { + onRegularText(text) } } @Composable fun CreateTextWithEmoji( text: String, - emojis: ImmutableMap, + tags: ImmutableListOfLists?, color: Color = Color.Unspecified, textAlign: TextAlign? = null, fontWeight: FontWeight? = null, @@ -411,51 +472,91 @@ fun CreateTextWithEmoji( overflow: TextOverflow = TextOverflow.Clip, modifier: Modifier = Modifier, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - - if (emojis.isNotEmpty()) { - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } - } - } - val textColor = color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) - } else { - val currentStyle = LocalTextStyle.current - val style = - remember(currentStyle) { - currentStyle + CustomEmojiChecker( + text, + tags, + onEmojiText = { + val style = + LocalTextStyle.current .merge( TextStyle( color = textColor, - textAlign = textAlign, + textAlign = TextAlign.Unspecified, fontWeight = fontWeight, fontSize = fontSize, ), ) .toSpanStyle() - } - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } + InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier) + }, + onRegularText = { + Text( + text = it, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) + }, + ) +} + +@Composable +fun CreateTextWithEmoji( + text: String, + emojis: ImmutableMap, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, +) { + val textColor = + color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } + + CustomEmojiChecker( + text, + emojis, + onEmojiText = { + val currentStyle = LocalTextStyle.current + val style = + remember(currentStyle) { + currentStyle + .merge( + TextStyle( + color = textColor, + textAlign = TextAlign.Unspecified, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() + } + + InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier) + }, + onRegularText = { + Text( + text = it, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) + }, + ) } @Composable @@ -466,126 +567,62 @@ fun CreateClickableTextWithEmoji( style: TextStyle, onClick: (Int) -> Unit, ) { - var emojiList by - remember(clickablePart) { mutableStateOf>(persistentListOf()) } - - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(clickablePart, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } - } - } - - if (emojiList.isEmpty()) { - ClickableText( - text = AnnotatedString(clickablePart), - style = style, - maxLines = maxLines, - onClick = onClick, - ) - } else { - ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) } - } + CustomEmojiChecker( + text = clickablePart, + tags = tags, + onRegularText = { + ClickableText( + text = AnnotatedString(clickablePart), + style = style, + maxLines = maxLines, + onClick = onClick, + ) + }, + onEmojiText = { + ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle()) { onClick(it) } + }, + ) } -@Immutable -data class DoubleEmojiList( - val part1: ImmutableList, - val part2: ImmutableList, -) - @Composable fun CreateClickableTextWithEmoji( clickablePart: String, - suffix: String?, maxLines: Int = Int.MAX_VALUE, overrideColor: Color? = null, fontWeight: FontWeight = FontWeight.Normal, + fontSize: TextUnit = TextUnit.Unspecified, route: String, nav: (String) -> Unit, tags: ImmutableListOfLists?, ) { - var emojiLists by remember(clickablePart) { mutableStateOf(null) } - - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) - val newEmojiList2 = - suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList() - - if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { - emojiLists = - DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) - } - } - } - } - - if (emojiLists == null) { - CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav) - } else { - ClickableInLineIconRenderer( - emojiLists!!.part1, - maxLines, - LocalTextStyle.current - .copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight) - .toSpanStyle(), - ) { - nav(route) - } - - InLineIconRenderer( - emojiLists!!.part2, - LocalTextStyle.current - .copy( - color = overrideColor ?: MaterialTheme.colorScheme.onBackground, + CustomEmojiChecker( + text = clickablePart, + tags = tags, + onRegularText = { + CreateClickableText(it, null, maxLines, overrideColor, fontWeight, fontSize, route, nav) + }, + onEmojiText = { + val clickablePartStyle = + SpanStyle( + fontSize = fontSize, + color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight, ) - .toSpanStyle(), - maxLines = maxLines, - ) - } -} -suspend fun assembleAnnotatedList( - text: String, - emojis: Map, -): ImmutableList { - return Nip30CustomEmoji() - .buildArray(text) - .map { - val url = emojis[it] - if (url != null) { - ImageUrlType(url) - } else { - TextType(it) + ClickableInLineIconRenderer( + it, + maxLines, + clickablePartStyle, + ) { + nav(route) } - } - .toImmutableList() + }, + ) } -@Immutable open class Renderable() - -@Immutable class TextType(val text: String) : Renderable() - -@Immutable class ImageUrlType(val url: String) : Renderable() - @Composable fun ClickableInLineIconRenderer( - wordsInOrder: ImmutableList, + wordsInOrder: ImmutableList, maxLines: Int = Int.MAX_VALUE, style: SpanStyle, onClick: (Int) -> Unit, @@ -602,7 +639,7 @@ fun ClickableInLineIconRenderer( val inlineContent = wordsInOrder .mapIndexedNotNull { idx, value -> - if (value is ImageUrlType) { + if (value is Nip30CustomEmoji.ImageUrlType) { Pair( "inlineContent$idx", InlineTextContent( @@ -631,9 +668,9 @@ fun ClickableInLineIconRenderer( withStyle( style, ) { - if (value is TextType) { + if (value is Nip30CustomEmoji.TextType) { append(value.text) - } else if (value is ImageUrlType) { + } else if (value is Nip30CustomEmoji.ImageUrlType) { appendInlineContent("inlineContent$idx", "[icon]") } } @@ -648,7 +685,7 @@ fun ClickableInLineIconRenderer( } } - BasicText( + Text( text = annotatedText, modifier = pressIndicator, inlineContent = inlineContent, @@ -659,7 +696,7 @@ fun ClickableInLineIconRenderer( @Composable fun InLineIconRenderer( - wordsInOrder: ImmutableList, + wordsInOrder: ImmutableList, style: SpanStyle, fontSize: TextUnit = TextUnit.Unspecified, maxLines: Int = Int.MAX_VALUE, @@ -678,7 +715,7 @@ fun InLineIconRenderer( val inlineContent = wordsInOrder .mapIndexedNotNull { idx, value -> - if (value is ImageUrlType) { + if (value is Nip30CustomEmoji.ImageUrlType) { Pair( "inlineContent$idx", InlineTextContent( @@ -708,9 +745,9 @@ fun InLineIconRenderer( withStyle( style, ) { - if (value is TextType) { + if (value is Nip30CustomEmoji.TextType) { append(value.text) - } else if (value is ImageUrlType) { + } else if (value is Nip30CustomEmoji.ImageUrlType) { appendInlineContent("inlineContent$idx", "[icon]") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index 30f6f5b8b..8089226d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt index 38db30210..aa40a20a6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 9e59b88ba..c9c2b3250 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 8128f54b7..477e7ac2b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -44,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ExpandableTextCutOffCalculator import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -51,9 +51,6 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground import com.vitorpamplona.quartz.events.ImmutableListOfLists -const val SHORT_TEXT_LENGTH = 350 -const val SHORTEN_AFTER_LINES = 10 - @Composable fun ExpandableRichTextViewer( content: String, @@ -66,30 +63,7 @@ fun ExpandableRichTextViewer( ) { var showFullText by remember { mutableStateOf(false) } - val whereToCut = - remember(content) { - // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters - val firstSpaceAfterCut = - content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } - val firstNewLineAfterCut = - content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } - - // or after SHORTEN_AFTER_LINES lines - val numberOfLines = content.count { it == '\n' } - - var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut) - - if (numberOfLines > SHORTEN_AFTER_LINES) { - val shortContent = content.lines().take(SHORTEN_AFTER_LINES) - charactersInLines = 0 - for (line in shortContent) { - // +1 because new line character is omitted from .lines - charactersInLines += (line.length + 1) - } - } - - minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines) - } + val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) } val text by remember(content) { @@ -103,17 +77,15 @@ fun ExpandableRichTextViewer( } Box { - Crossfade(text, label = "ExpandableRichTextViewer") { - RichTextViewer( - it, - canPreview, - modifier.align(Alignment.TopStart), - tags, - backgroundColor, - accountViewModel, - nav, - ) - } + RichTextViewer( + text, + canPreview, + modifier.align(Alignment.TopStart), + tags, + backgroundColor, + accountViewModel, + nav, + ) if (content.length > whereToCut && !showFullText) { Row( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt index 4e9a06161..6817b873f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 3f1213cee..497f70f51 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.encoders.LnInvoiceUtil +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.text.NumberFormat @@ -79,6 +80,7 @@ fun LoadValueFromInvoice( try { NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 786aa0cd8..12a91502a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index dc8b0b5ab..2d71b7079 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -29,10 +29,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding -import kotlinx.collections.immutable.persistentListOf @Composable fun LoadUrlPreview( @@ -68,19 +69,17 @@ fun LoadUrlPreview( if (state.previewInfo.mimeType.type == "image") { Box(modifier = HalfVertPadding) { ZoomableContentView( - ZoomableUrlImage(url), - persistentListOf(), + content = MediaUrlImage(url), roundedCorner = true, - accountViewModel, + accountViewModel = accountViewModel, ) } } else if (state.previewInfo.mimeType.type == "video") { Box(modifier = HalfVertPadding) { ZoomableContentView( - ZoomableUrlVideo(url), - persistentListOf(), + content = MediaUrlVideo(url), roundedCorner = true, - accountViewModel, + accountViewModel = accountViewModel, ) } } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt index eabab7c90..8ed1e6e49 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -22,23 +22,26 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Log import android.util.Patterns +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.coroutines.CancellationException class MarkdownParser { private fun getDisplayNameAndNIP19FromTag( tag: String, tags: ImmutableListOfLists, ): Pair? { - val matcher = tagIndex.matcher(tag) + val matcher = RichTextParser.tagIndex.matcher(tag) val (index, suffix) = try { matcher.find() Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") } catch (e: Exception) { + if (e is CancellationException) throw e Log.w("Tag Parser", "Couldn't link tag $tag", e) Pair(null, null) } @@ -62,67 +65,88 @@ class MarkdownParser { return null } - private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? { - if (nip19.type == Nip19.Type.USER) { - LocalCache.users[nip19.hex]?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + private suspend fun getDisplayNameFromNip19(nip19: Nip19Bech32.Entity): Pair? { + return when (nip19) { + is Nip19Bech32.NSec -> null + is Nip19Bech32.NPub -> { + LocalCache.getUserIfExists(nip19.hex)?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } } - } else if (nip19.type == Nip19.Type.NOTE) { - LocalCache.notes[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) + is Nip19Bech32.NProfile -> { + LocalCache.getUserIfExists(nip19.hex)?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } } - } else if (nip19.type == Nip19.Type.ADDRESS) { - LocalCache.addressables[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) + is Nip19Bech32.Note -> { + LocalCache.getNoteIfExists(nip19.hex)?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } } - } else if (nip19.type == Nip19.Type.EVENT) { - LocalCache.notes[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) + is Nip19Bech32.NEvent -> { + LocalCache.getNoteIfExists(nip19.hex)?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } } - } + is Nip19Bech32.NEmbed -> { + if (LocalCache.getNoteIfExists(nip19.event.id) == null) { + LocalCache.verifyAndConsume(nip19.event, null) + } - return null + LocalCache.getNoteIfExists(nip19.event.id)?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } + is Nip19Bech32.NRelay -> null + is Nip19Bech32.NAddress -> { + LocalCache.getAddressableNoteIfExists(nip19.atag)?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } + else -> null + } } fun returnNIP19References( content: String, tags: ImmutableListOfLists?, - ): List { + ): List { checkNotInMainThread() - val listOfReferences = mutableListOf() + val listOfReferences = mutableListOf() content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> - if (startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19.uriToRoute(word) - parsedNip19?.let { listOfReferences.add(it) } + if (RichTextParser.startsWithNIP19Scheme(word)) { + val parsedNip19 = Nip19Bech32.uriToRoute(word) + parsedNip19?.let { listOfReferences.add(it.entity) } } } } tags?.lists?.forEach { if (it[0] == "p" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, "")) + listOfReferences.add(Nip19Bech32.NProfile(it[1], listOfNotNull(it.getOrNull(2)))) } else if (it[0] == "e" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, "")) + listOfReferences.add(Nip19Bech32.NEvent(it[1], listOfNotNull(it.getOrNull(2)), null, null)) } else if (it[0] == "a" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, "")) + ATag.parseAtag(it[1], it.getOrNull(2))?.let { atag -> + listOfReferences.add(Nip19Bech32.NAddress(it[1], listOfNotNull(atag.relay), atag.pubKeyHex, atag.kind)) + } } } return listOfReferences } - fun returnMarkdownWithSpecialContent( + suspend fun returnMarkdownWithSpecialContent( content: String, tags: ImmutableListOfLists?, ): String { var returnContent = "" content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> - if (isValidURL(word)) { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + if (RichTextParser.isValidURL(word)) { + if (RichTextParser.isImageUrl(word)) { returnContent += "![]($word) " } else { returnContent += "[$word]($word) " @@ -131,11 +155,11 @@ class MarkdownParser { returnContent += "[$word](mailto:$word) " } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { returnContent += "[$word](tel:$word) " - } else if (startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19.uriToRoute(word) + } else if (RichTextParser.startsWithNIP19Scheme(word)) { + val parsedNip19 = Nip19Bech32.uriToRoute(word) returnContent += - if (parsedNip19 !== null) { - val pair = getDisplayNameFromNip19(parsedNip19) + if (parsedNip19?.entity !== null) { + val pair = getDisplayNameFromNip19(parsedNip19.entity) if (pair != null) { val (displayName, nip19) = pair "[$displayName](nostr:$nip19) " @@ -146,21 +170,22 @@ class MarkdownParser { "$word " } } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { + if (RichTextParser.tagIndex.matcher(word).matches() && tags != null) { val pair = getDisplayNameAndNIP19FromTag(word, tags) if (pair != null) { returnContent += "[${pair.first}](nostr:${pair.second}) " } else { returnContent += "$word " } - } else if (hashTagsPattern.matcher(word).matches()) { - val hashtagMatcher = hashTagsPattern.matcher(word) + } else if (RichTextParser.hashTagsPattern.matcher(word).matches()) { + val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word) val (myTag, mySuffix) = try { hashtagMatcher.find() Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) Pair(null, null) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt index 0e9d5377c..bbafab0de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -33,6 +33,7 @@ import com.abedelazizshe.lightcompressorlibrary.config.Configuration import com.vitorpamplona.amethyst.service.checkNotInMainThread import id.zelory.compressor.Compressor import id.zelory.compressor.constraint.default +import kotlinx.coroutines.CancellationException import java.io.File import java.io.FileOutputStream import java.util.UUID @@ -115,6 +116,7 @@ class MediaCompressor { } onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) } catch (e: Exception) { + if (e is CancellationException) throw e e.printStackTrace() onReady(uri, contentType, null) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a22826de7..e6d8a1fef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -28,11 +28,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle @@ -49,7 +48,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection @@ -62,37 +60,43 @@ import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import androidx.lifecycle.viewmodel.compose.viewModel import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.material3.Material3RichText +import com.vitorpamplona.amethyst.commons.BechSegment +import com.vitorpamplona.amethyst.commons.CashuSegment +import com.vitorpamplona.amethyst.commons.EmailSegment +import com.vitorpamplona.amethyst.commons.EmojiSegment +import com.vitorpamplona.amethyst.commons.HashIndexEventSegment +import com.vitorpamplona.amethyst.commons.HashIndexUserSegment +import com.vitorpamplona.amethyst.commons.HashTagSegment +import com.vitorpamplona.amethyst.commons.ImageSegment +import com.vitorpamplona.amethyst.commons.InvoiceSegment +import com.vitorpamplona.amethyst.commons.LinkSegment +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.PhoneSegment +import com.vitorpamplona.amethyst.commons.RegularTextSegment +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.RichTextViewerState +import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.Segment +import com.vitorpamplona.amethyst.commons.WithdrawSegment +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon -import com.vitorpamplona.amethyst.service.BechSegment import com.vitorpamplona.amethyst.service.CachedRichTextParser -import com.vitorpamplona.amethyst.service.CashuSegment -import com.vitorpamplona.amethyst.service.EmailSegment -import com.vitorpamplona.amethyst.service.EmojiSegment -import com.vitorpamplona.amethyst.service.HashIndexEventSegment -import com.vitorpamplona.amethyst.service.HashIndexUserSegment -import com.vitorpamplona.amethyst.service.HashTagSegment -import com.vitorpamplona.amethyst.service.ImageSegment -import com.vitorpamplona.amethyst.service.InvoiceSegment -import com.vitorpamplona.amethyst.service.LinkSegment -import com.vitorpamplona.amethyst.service.PhoneSegment -import com.vitorpamplona.amethyst.service.RegularTextSegment -import com.vitorpamplona.amethyst.service.RichTextParser -import com.vitorpamplona.amethyst.service.RichTextViewerState -import com.vitorpamplona.amethyst.service.SchemelessUrlSegment -import com.vitorpamplona.amethyst.service.Segment -import com.vitorpamplona.amethyst.service.WithdrawSegment import com.vitorpamplona.amethyst.ui.note.LoadUser import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink import com.vitorpamplona.amethyst.ui.theme.Font17SP @@ -100,51 +104,17 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.markdownStyle -import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.uriToRoute -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists +import fr.acinq.secp256k1.Hex +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import java.net.MalformedURLException -import java.net.URISyntaxException -import java.net.URL -import java.util.regex.Pattern - -val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") -val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") - -val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") -val hashTagsPattern: Pattern = - Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) - -fun removeQueryParamsForExtensionComparison(fullUrl: String): String { - return if (fullUrl.contains("?")) { - fullUrl.split("?")[0].lowercase() - } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0].lowercase() - } else { - fullUrl.lowercase() - } -} - -fun isImageOrVideoUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - - return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || - videoExtensions.any { removedParamsFromUrl.endsWith(it) } -} - -fun isValidURL(url: String?): Boolean { - return try { - URL(url).toURI() - true - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } -} fun isMarkdown(content: String): Boolean { return content.startsWith("> ") || @@ -174,7 +144,134 @@ fun RichTextViewer( } } -@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +fun RenderRegularPreview() { + val nav: (String) -> Unit = {} + + Column(modifier = Modifier.padding(10.dp)) { + RenderRegular( + "nostr:npub1e0z776cpe0gllgktjk54fuzv8pdfxmq6smsmh8xd7t8s7n474n9smk0txy but i'm Monthly funding" + + " 7 other humans vitor@vitorpamplona.com at the moment so spread #test a bit thin, but won't always be the case.", + EmptyTagList, + ) { word, state -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + is BechSegment -> { + CreateClickableText( + word.segmentText.substring(0, 10), + "", + 1, + route = "", + nav = nav, + ) + } + + is HashTagSegment -> HashTag(word, nav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) + } + } + } +} + +@Preview +@Composable +fun RenderRegularPreview2() { + val nav: (String) -> Unit = {} + RenderRegular( + "#Amethyst v0.84.1: ncryptsec support (NIP-49)", + EmptyTagList, + ) { word, state -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) + } + } +} + +@Composable +fun mockAccountViewModel(): AccountViewModel { + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + sharedPreferencesViewModel.init() + + return AccountViewModel( + Account( + // blank keys + keyPair = + KeyPair( + privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), + pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"), + forcePubKeyCheck = false, + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ), + sharedPreferencesViewModel.sharedPrefs, + ) +} + +@Preview +@Composable +fun RenderRegularPreview3() { + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ), + ) + val nav: (String) -> Unit = {} + val accountViewModel = mockAccountViewModel() + + RenderRegular( + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow", + tags, + ) { word, state -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) + } + } +} + @Composable private fun RenderRegular( content: String, @@ -184,75 +281,65 @@ private fun RenderRegular( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } + RenderRegular(content, tags) { word, state -> + if (canPreview) { + RenderWordWithPreview( + word, + state, + backgroundColor, + accountViewModel, + nav, + ) + } else { + RenderWordWithoutPreview( + word, + state, + backgroundColor, + accountViewModel, + nav, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RenderRegular( + content: String, + tags: ImmutableListOfLists, + wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit, +) { + val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } + + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) val currentTextStyle = LocalTextStyle.current - val currentTextColor = LocalContentColor.current val textStyle = - remember(currentTextStyle, currentTextColor) { + remember(currentTextStyle) { currentTextStyle.copy( lineHeight = 1.4.em, - color = currentTextStyle.color.takeOrElse { currentTextColor }, ) } - val spaceWidth = measureSpaceWidth(textStyle) - Column { - if (canPreview) { - // FlowRow doesn't work well with paragraphs. So we need to split them - state.paragraphs.forEach { paragraph -> - val direction = + // FlowRow doesn't work well with paragraphs. So we need to split them + state.paragraphs.forEach { paragraph -> + CompositionLocalProvider( + LocalLayoutDirection provides if (paragraph.isRTL) { LayoutDirection.Rtl } else { LayoutDirection.Ltr - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - state, - backgroundColor, - textStyle, - accountViewModel, - nav, - ) - } - } - } - } - } else { - // FlowRow doesn't work well with paragraphs. So we need to split them - state.paragraphs.forEach { paragraph -> - val direction = - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - ) { - paragraph.words.forEach { word -> - RenderWordWithoutPreview( - word, - state, - backgroundColor, - textStyle, - accountViewModel, - nav, - ) - } + }, + LocalTextStyle provides textStyle, + ) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + wordRenderer(word, state) } } } @@ -281,7 +368,6 @@ private fun RenderWordWithoutPreview( word: Segment, state: RichTextViewerState, backgroundColor: MutableState, - style: TextStyle, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -291,10 +377,10 @@ private fun RenderWordWithoutPreview( is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) // Don't offer to pay invoices - is InvoiceSegment -> NormalWord(word.segmentText, style) + is InvoiceSegment -> Text(word.segmentText) // Don't offer to withdraw - is WithdrawSegment -> NormalWord(word.segmentText, style) - is CashuSegment -> NormalWord(word.segmentText, style) + is WithdrawSegment -> Text(word.segmentText) + is CashuSegment -> Text(word.segmentText) is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav) @@ -302,7 +388,7 @@ private fun RenderWordWithoutPreview( is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> NormalWord(word.segmentText, style) + is RegularTextSegment -> Text(word.segmentText) } } @@ -311,7 +397,6 @@ private fun RenderWordWithPreview( word: Segment, state: RichTextViewerState, backgroundColor: MutableState, - style: TextStyle, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -329,7 +414,7 @@ private fun RenderWordWithPreview( is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> NormalWord(word.segmentText, style) + is RegularTextSegment -> Text(word.segmentText) } } @@ -347,23 +432,7 @@ private fun ZoomableContentView( } @Composable -private fun NormalWord( - word: String, - style: TextStyle, -) { - BasicText( - text = word, - style = style, - ) -} - -@Composable -private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) { - RenderUrl(word) -} - -@Composable -private fun RenderUrl(segment: SchemelessUrlSegment) { +private fun NoProtocolUrlRenderer(segment: SchemelessUrlSegment) { ClickableUrl(segment.url, "https://${segment.url}") segment.extras?.let { it1 -> Text(it1) } } @@ -382,7 +451,7 @@ fun RenderCustomEmoji( val markdownParseOptions = MarkdownParseOptions( autolink = true, - isImage = { url -> isImageOrVideoUrl(url) }, + isImage = { url -> RichTextParser.isImageOrVideoUrl(url) }, ) @Composable @@ -416,8 +485,8 @@ private fun RenderContentAsMarkdown( onMediaCompose = { title, destination -> ZoomableContentView( content = - remember(destination) { - RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination) + remember(destination, tags) { + RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: MediaUrlImage(url = destination) }, roundedCorner = true, accountViewModel = accountViewModel, @@ -439,9 +508,7 @@ private fun RefreshableContent( var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } ObserverAllNIP19References(content, tags, accountViewModel) { - accountViewModel.returnMarkdownWithSpecialContent(content, tags) { - newMarkdownWithSpecialContent, - -> + accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent -> if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { markdownWithSpecialContent = newMarkdownWithSpecialContent } @@ -458,7 +525,7 @@ fun ObserverAllNIP19References( accountViewModel: AccountViewModel, onRefresh: () -> Unit, ) { - var nip19References by remember(content) { mutableStateOf>(emptyList()) } + var nip19References by remember(content) { mutableStateOf>(emptyList()) } LaunchedEffect(key1 = content) { accountViewModel.returnNIP19References(content, tags) { @@ -472,33 +539,37 @@ fun ObserverAllNIP19References( @Composable fun ObserveNIP19( - it: Nip19.Return, + entity: Nip19Bech32.Entity, accountViewModel: AccountViewModel, onRefresh: () -> Unit, ) { - if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { - ObserveNIP19Event(it, accountViewModel, onRefresh) - } else if (it.type == Nip19.Type.USER) { - ObserveNIP19User(it, accountViewModel, onRefresh) + when (val parsed = entity) { + is Nip19Bech32.NPub -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh) + is Nip19Bech32.NProfile -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh) + + is Nip19Bech32.Note -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh) + is Nip19Bech32.NEvent -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh) + is Nip19Bech32.NEmbed -> ObserveNIP19Event(parsed.event.id, accountViewModel, onRefresh) + + is Nip19Bech32.NAddress -> ObserveNIP19Event(parsed.atag, accountViewModel, onRefresh) + + is Nip19Bech32.NSec -> {} + is Nip19Bech32.NRelay -> {} } } @Composable private fun ObserveNIP19Event( - it: Nip19.Return, + hex: HexKey, accountViewModel: AccountViewModel, onRefresh: () -> Unit, ) { - var baseNote by remember(it) { mutableStateOf(accountViewModel.getNoteIfExists(it.hex)) } + var baseNote by remember(hex) { mutableStateOf(accountViewModel.getNoteIfExists(hex)) } if (baseNote == null) { - LaunchedEffect(key1 = it.hex) { - if ( - it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS - ) { - accountViewModel.checkGetOrCreateNote(it.hex) { note -> - launch(Dispatchers.Main) { baseNote = note } - } + LaunchedEffect(key1 = hex) { + accountViewModel.checkGetOrCreateNote(hex) { note -> + launch(Dispatchers.Main) { baseNote = note } } } } @@ -522,18 +593,16 @@ fun ObserveNote( @Composable private fun ObserveNIP19User( - it: Nip19.Return, + hex: HexKey, accountViewModel: AccountViewModel, onRefresh: () -> Unit, ) { - var baseUser by remember(it) { mutableStateOf(accountViewModel.getUserIfExists(it.hex)) } + var baseUser by remember(hex) { mutableStateOf(accountViewModel.getUserIfExists(hex)) } if (baseUser == null) { - LaunchedEffect(key1 = it.hex) { - if (it.type == Nip19.Type.USER) { - accountViewModel.checkGetOrCreateUser(it.hex)?.let { user -> - launch(Dispatchers.Main) { baseUser = user } - } + LaunchedEffect(key1 = hex) { + accountViewModel.checkGetOrCreateUser(hex)?.let { user -> + launch(Dispatchers.Main) { baseUser = user } } } } @@ -566,7 +635,9 @@ fun BechLink( var loadedLink by remember { mutableStateOf(null) } if (loadedLink == null) { - LaunchedEffect(key1 = word) { accountViewModel.parseNIP19(word) { loadedLink = it } } + LaunchedEffect(key1 = word) { + accountViewModel.parseNIP19(word) { loadedLink = it } + } } if (canPreview && loadedLink?.baseNote != null) { @@ -580,7 +651,7 @@ fun BechLink( ) } } else if (loadedLink?.nip19 != null) { - Row { ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) } + ClickableRoute(word, loadedLink?.nip19!!, accountViewModel, nav) } else { val text = remember(word) { @@ -606,7 +677,7 @@ private fun DisplayFullNote( NoteCompose( baseNote = it, accountViewModel = accountViewModel, - modifier = MaterialTheme.colorScheme.replyModifier, + modifier = MaterialTheme.colorScheme.innerPostModifier, parentBackgroundColor = backgroundColor, isQuotedNote = true, nav = nav, @@ -623,41 +694,35 @@ private fun DisplayFullNote( @Composable fun HashTag( - word: HashTagSegment, - nav: (String) -> Unit, -) { - RenderHashtag(word, nav) -} - -@Composable -private fun RenderHashtag( segment: HashTagSegment, nav: (String) -> Unit, ) { val primary = MaterialTheme.colorScheme.primary val background = MaterialTheme.colorScheme.onBackground val hashtagIcon: HashtagIcon? = - remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) } + remember(segment.segmentText) { checkForHashtagWithIcon(segment.hashtag, primary) } val regularText = remember { SpanStyle(color = background) } val clickableTextStyle = remember { SpanStyle(color = primary) } val annotatedTermsString = - remember { + remember(segment.segmentText) { buildAnnotatedString { withStyle(clickableTextStyle) { pushStringAnnotation("routeToHashtag", "") append("#${segment.hashtag}") + pop() } if (hashtagIcon != null) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToHashtag", "") appendInlineContent("inlineContent", "[icon]") + pop() } } - segment.extras?.ifBlank { "" }?.let { withStyle(regularText) { append(it) } } + segment.extras?.let { withStyle(regularText) { append(it) } } } } @@ -704,7 +769,12 @@ fun TagLink( if (it == null) { Text(text = word.segmentText) } else { - Row { DisplayUserFromTag(it, word.extras, nav) } + Row { + DisplayUserFromTag(it, nav) + word.extras?.let { + Text(text = it) + } + } } } } @@ -781,7 +851,6 @@ private fun DisplayNoteFromTag( @Composable private fun DisplayUserFromTag( baseUser: User, - addedChars: String?, nav: (String) -> Unit, ) { val route = remember { "User/${baseUser.pubkeyHex}" } @@ -789,12 +858,11 @@ private fun DisplayUserFromTag( val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - Crossfade(targetState = meta) { + Crossfade(targetState = meta, label = "DisplayUserFromTag") { Row { val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex } CreateClickableTextWithEmoji( clickablePart = displayName, - suffix = addedChars, maxLines = 1, route = route, nav = nav, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index 4cca27d38..7262646a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,9 +30,10 @@ import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.request.ImageRequest import coil.request.Options +import com.vitorpamplona.amethyst.commons.Robohash import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.quartz.utils.Robohash -import okio.Buffer +import okio.buffer +import okio.source import java.nio.charset.Charset @Stable @@ -43,14 +44,10 @@ class HashImageFetcher( ) : Fetcher { override suspend fun fetch(): SourceResult { checkNotInMainThread() + val source = try { - val buffer = Buffer() - buffer.writeString( - Robohash.assemble(data.toString(), isLightTheme), - Charset.defaultCharset(), - ) - buffer + Robohash.assemble(data.toString(), isLightTheme).byteInputStream(Charset.defaultCharset()).source().buffer() } finally { } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index d119568cc..031fbf160 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -117,7 +117,7 @@ fun RobohashFallbackAsyncImage( Image( painter = base64Painter, - contentDescription = null, + contentDescription = contentDescription, modifier = modifier, alignment = alignment, contentScale = contentScale, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt index e7bf72a08..afda98789 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 008cb5792..fd19d837d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -74,8 +74,17 @@ fun SensitivityWarning( accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { - val hasSensitiveContent = remember(event) { event.isSensitive() ?: false } + val hasSensitiveContent = remember(event) { event.isSensitive() } + SensitivityWarning(hasSensitiveContent, accountViewModel, content) +} + +@Composable +fun SensitivityWarning( + hasSensitiveContent: Boolean, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { if (hasSensitiveContent) { SensitivityWarning(accountViewModel, content) } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 41d19c1af..047bb7bec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt index 569e0c6b7..6fedfb580 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -32,6 +32,15 @@ class SplitItem(val key: T) { class Split() { var items: List> by mutableStateOf(emptyList()) + fun addItem( + key: T, + percentage: Float, + ) { + val newItem = SplitItem(key) + newItem.percentage = percentage + this.items = items.plus(newItem) + } + fun addItem(key: T): Int { val wasEqualSplit = isEqualSplit() val newItem = SplitItem(key) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index d95aab102..8c2141b05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt index e06228df8..379b171a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 983613ee6..4eb4e5681 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt index a37b3e116..beef6d771 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index ddd58eede..40001604b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -106,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay @@ -356,6 +357,7 @@ fun GetMediaItem( null } } catch (e: Exception) { + if (e is CancellationException) throw e null }, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index c2b5c5114..428e1086f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index b002f1854..9de3bc620 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -69,7 +69,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect @@ -105,8 +104,15 @@ import androidx.core.view.ViewCompat import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter -import coil.imageLoader +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaLocalImage +import com.vitorpamplona.amethyst.commons.MediaLocalVideo +import com.vitorpamplona.amethyst.commons.MediaPreloadedContent +import com.vitorpamplona.amethyst.commons.MediaUrlContent +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.InformationDialog @@ -131,107 +137,23 @@ import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.toHexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -import java.io.File - -@Immutable -abstract class ZoomableContent( - val description: String? = null, - val dim: String? = null, -) - -@Immutable -abstract class ZoomableUrlContent( - val url: String, - description: String? = null, - val hash: String? = null, - dim: String? = null, - val uri: String? = null, -) : ZoomableContent(description, dim) - -@Immutable -class ZoomableUrlImage( - url: String, - description: String? = null, - hash: String? = null, - val blurhash: String? = null, - dim: String? = null, - uri: String? = null, -) : ZoomableUrlContent(url, description, hash, dim, uri) - -@Immutable -class ZoomableUrlVideo( - url: String, - description: String? = null, - hash: String? = null, - dim: String? = null, - uri: String? = null, - val artworkUri: String? = null, - val authorName: String? = null, - val blurhash: String? = null, -) : ZoomableUrlContent(url, description, hash, dim, uri) - -@Immutable -abstract class ZoomablePreloadedContent( - val localFile: File?, - description: String? = null, - val mimeType: String? = null, - val isVerified: Boolean? = null, - dim: String? = null, - val uri: String, -) : ZoomableContent(description, dim) - -@Immutable -class ZoomableLocalImage( - localFile: File?, - mimeType: String? = null, - description: String? = null, - val blurhash: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, -) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) - -@Immutable -class ZoomableLocalVideo( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, - val artworkUri: String? = null, - val authorName: String? = null, -) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) - -fun figureOutMimeType(fullUrl: String): ZoomableContent { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - - return if (isImage) { - ZoomableUrlImage(fullUrl) - } else if (isVideo) { - ZoomableUrlVideo(fullUrl) - } else { - ZoomableUrlImage(fullUrl) - } -} @Composable @OptIn(ExperimentalFoundationApi::class) fun ZoomableContentView( - content: ZoomableContent, - images: ImmutableList = listOf(content).toImmutableList(), + content: BaseMediaContent, + images: ImmutableList = remember(content) { listOf(content).toImmutableList() }, roundedCorner: Boolean, accountViewModel: AccountViewModel, ) { // store the dialog open or close state - var dialogOpen by remember { mutableStateOf(false) } + var dialogOpen by remember(content) { mutableStateOf(false) } // store the dialog open or close state val shareOpen = remember { mutableStateOf(false) } @@ -247,13 +169,13 @@ fun ZoomableContentView( Modifier.fillMaxWidth() } - if (content is ZoomableUrlContent) { + if (content is MediaUrlContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, onLongClick = { shareOpen.value = true }, ) - } else if (content is ZoomablePreloadedContent) { + } else if (content is MediaPreloadedContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, @@ -264,24 +186,28 @@ fun ZoomableContentView( } when (content) { - is ZoomableUrlImage -> - UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel) - is ZoomableUrlVideo -> - VideoView( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - dimensions = content.dim, - blurhash = content.blurhash, - roundedCorner = roundedCorner, - nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, - accountViewModel = accountViewModel, - ) - is ZoomableLocalImage -> + is MediaUrlImage -> + SensitivityWarning(content.contentWarning != null, accountViewModel) { + UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel) + } + is MediaUrlVideo -> + SensitivityWarning(content.contentWarning != null, accountViewModel) { + VideoView( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + dimensions = content.dim, + blurhash = content.blurhash, + roundedCorner = roundedCorner, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, + accountViewModel = accountViewModel, + ) + } + is MediaLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel) - is ZoomableLocalVideo -> + is MediaLocalVideo -> content.localFile?.let { VideoView( videoUri = it.toUri().toString(), @@ -303,13 +229,13 @@ fun ZoomableContentView( @Composable private fun LocalImageView( - content: ZoomableLocalImage, + content: MediaLocalImage, mainImageModifier: Modifier, topPaddingForControllers: Dp = Dp.Unspecified, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, ) { - if (content.localFile != null && content.localFile.exists()) { + if (content.localFileExists()) { BoxWithConstraints(contentAlignment = Alignment.Center) { val showImage = remember { @@ -370,7 +296,7 @@ private fun LocalImageView( @Composable private fun UrlImageView( - content: ZoomableUrlImage, + content: MediaUrlImage, mainImageModifier: Modifier, topPaddingForControllers: Dp = Dp.Unspecified, accountViewModel: AccountViewModel, @@ -379,7 +305,7 @@ private fun UrlImageView( BoxWithConstraints(contentAlignment = Alignment.Center) { val showImage = remember { - mutableStateOf( + mutableStateOf( if (alwayShowImage) true else accountViewModel.settings.showImages.value, ) } @@ -387,19 +313,9 @@ private fun UrlImageView( val myModifier = remember { mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) - /* Is this necessary? It makes images bleed into other pages - .run { - aspectRatio(content.dim)?.let { ratio -> - this.aspectRatio(ratio, false) - } ?: this - } - */ } - val contentScale = - remember { - if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth - } + val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth val verifierModifier = if (topPaddingForControllers.isSpecified) { @@ -416,7 +332,9 @@ private fun UrlImageView( contentDescription = content.description, contentScale = contentScale, modifier = myModifier, - onState = { painterState.value = it }, + onState = { + painterState.value = it + }, ) } @@ -450,11 +368,13 @@ fun ImageUrlWithDownloadButton( withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") append("$url ") + pop() } withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") appendInlineContent("inlineContent", "[icon]") + pop() } withStyle(regularText) { append(" ") } @@ -493,7 +413,7 @@ private fun InlineDownloadIcon(showImage: MutableState) = @OptIn(ExperimentalLayoutApi::class) private fun AddedImageFeatures( painter: MutableState, - content: ZoomableLocalImage, + content: MediaLocalImage, contentScale: ContentScale, myModifier: Modifier, verifiedModifier: Modifier, @@ -542,8 +462,8 @@ private fun AddedImageFeatures( BlankNote() } is AsyncImagePainter.State.Success -> { - if (content.isVerified != null) { - HashVerificationSymbol(content.isVerified, verifiedModifier) + content.isVerified?.let { + HashVerificationSymbol(it, verifiedModifier) } } else -> {} @@ -555,13 +475,13 @@ private fun AddedImageFeatures( @OptIn(ExperimentalLayoutApi::class) private fun AddedImageFeatures( painter: MutableState, - content: ZoomableUrlImage, + content: MediaUrlImage, contentScale: ContentScale, myModifier: Modifier, verifiedModifier: Modifier, showImage: MutableState, ) { - val ratio = remember { aspectRatio(content.dim) } + val ratio = remember(content.url) { aspectRatio(content.dim) } if (!showImage.value) { if (content.blurhash != null && ratio != null) { @@ -581,7 +501,7 @@ private fun AddedImageFeatures( ImageUrlWithDownloadButton(content.url, showImage) } } else { - var verifiedHash by remember { mutableStateOf(null) } + var verifiedHash by remember(content.url) { mutableStateOf(null) } when (painter.value) { null, @@ -609,10 +529,9 @@ private fun AddedImageFeatures( } is AsyncImagePainter.State.Success -> { if (content.hash != null) { - val context = LocalContext.current LaunchedEffect(key1 = content.url) { launch(Dispatchers.IO) { - val newVerifiedHash = verifyHash(content, context) + val newVerifiedHash = verifyHash(content) if (newVerifiedHash != verifiedHash) { verifiedHash = newVerifiedHash } @@ -644,13 +563,14 @@ fun aspectRatio(dim: String?): Float? { width / height } } catch (e: Exception) { + if (e is CancellationException) throw e null } } @Composable -private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - var cnt by remember { mutableStateOf(null) } +private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { + var cnt by remember { mutableStateOf(null) } LaunchedEffect(Unit) { launch(Dispatchers.IO) { @@ -663,7 +583,7 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { } @Composable -private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { +private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { val uri = LocalUriHandler.current val primary = MaterialTheme.colorScheme.primary @@ -675,10 +595,11 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { val annotatedTermsString = remember { buildAnnotatedString { - if (content is ZoomableUrlContent) { + if (content is MediaUrlContent) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") append(content.url + " ") + pop() } } else { withStyle(regularText) { append("Loading content...") } @@ -687,6 +608,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") appendInlineContent("inlineContent", "[icon]") + pop() } withStyle(regularText) { append(" ") } @@ -697,7 +619,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { val pressIndicator = remember { - if (content is ZoomableUrlContent) { + if (content is MediaUrlContent) { Modifier.clickable { runCatching { uri.openUri(content.url) } } } else { Modifier @@ -749,8 +671,8 @@ fun DisplayBlurHash( @Composable fun ZoomableImageDialog( - imageUrl: ZoomableContent, - allImages: ImmutableList = listOf(imageUrl).toImmutableList(), + imageUrl: BaseMediaContent, + allImages: ImmutableList = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { @@ -813,8 +735,8 @@ fun ZoomableImageDialog( @Composable @OptIn(ExperimentalFoundationApi::class) private fun DialogContent( - allImages: ImmutableList, - imageUrl: ZoomableContent, + allImages: ImmutableList, + imageUrl: BaseMediaContent, onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { @@ -839,14 +761,16 @@ private fun DialogContent( SlidingCarousel( pagerState = pagerState, ) { index -> - RenderImageOrVideo( - content = allImages[index], - roundedCorner = false, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, - accountViewModel = accountViewModel, - ) + allImages.getOrNull(index)?.let { + RenderImageOrVideo( + content = it, + roundedCorner = false, + topPaddingForControllers = Size55dp, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, + accountViewModel = accountViewModel, + ) + } } } else { RenderImageOrVideo( @@ -871,18 +795,19 @@ private fun DialogContent( ) { CloseButton(onPress = onDismiss) - val myContent = allImages[pagerState.currentPage] - if (myContent is ZoomableUrlContent) { - Row { - CopyToClipboard(content = myContent) - Spacer(modifier = StdHorzSpacer) - SaveToGallery(url = myContent.url) + allImages.getOrNull(pagerState.currentPage)?.let { myContent -> + if (myContent is MediaUrlContent) { + Row { + CopyToClipboard(content = myContent) + Spacer(modifier = StdHorzSpacer) + SaveToGallery(url = myContent.url) + } + } else if (myContent is MediaLocalImage && myContent.localFileExists()) { + SaveToGallery( + localFile = myContent.localFile!!, + mimeType = myContent.mimeType, + ) } - } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { - SaveToGallery( - localFile = myContent.localFile, - mimeType = myContent.mimeType, - ) } } } @@ -927,7 +852,7 @@ fun InlineCarrousel( } @Composable -private fun CopyToClipboard(content: ZoomableContent) { +private fun CopyToClipboard(content: BaseMediaContent) { val popupExpanded = remember { mutableStateOf(false) } OutlinedButton( @@ -947,7 +872,7 @@ private fun CopyToClipboard(content: ZoomableContent) { @Composable private fun ShareImageAction( popupExpanded: MutableState, - content: ZoomableContent, + content: BaseMediaContent, onDismiss: () -> Unit, ) { DropdownMenu( @@ -956,7 +881,7 @@ private fun ShareImageAction( ) { val clipboardManager = LocalClipboardManager.current - if (content is ZoomableUrlContent) { + if (content is MediaUrlContent) { DropdownMenuItem( text = { Text(stringResource(R.string.copy_url_to_clipboard)) }, onClick = { @@ -964,18 +889,18 @@ private fun ShareImageAction( onDismiss() }, ) - if (content.uri != null) { + content.uri?.let { DropdownMenuItem( text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, onClick = { - clipboardManager.setText(AnnotatedString(content.uri)) + clipboardManager.setText(AnnotatedString(it)) onDismiss() }, ) } } - if (content is ZoomablePreloadedContent) { + if (content is MediaPreloadedContent) { DropdownMenuItem( text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, onClick = { @@ -989,7 +914,7 @@ private fun ShareImageAction( @Composable private fun RenderImageOrVideo( - content: ZoomableContent, + content: BaseMediaContent, roundedCorner: Boolean, topPaddingForControllers: Dp = Dp.Unspecified, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, @@ -998,7 +923,7 @@ private fun RenderImageOrVideo( ) { val automaticallyStartPlayback = remember { mutableStateOf(true) } - if (content is ZoomableUrlImage) { + if (content is MediaUrlImage) { val mainModifier = Modifier.fillMaxSize() .zoomable( @@ -1017,7 +942,7 @@ private fun RenderImageOrVideo( accountViewModel, alwayShowImage = true, ) - } else if (content is ZoomableUrlVideo) { + } else if (content is MediaUrlVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { VideoViewInner( videoUri = content.url, @@ -1030,7 +955,7 @@ private fun RenderImageOrVideo( onControllerVisibilityChanged = onControllerVisibilityChanged, ) } - } else if (content is ZoomableLocalImage) { + } else if (content is MediaLocalImage) { val mainModifier = Modifier.fillMaxSize() .zoomable( @@ -1049,7 +974,7 @@ private fun RenderImageOrVideo( accountViewModel, alwayShowImage = true, ) - } else if (content is ZoomableLocalVideo) { + } else if (content is MediaLocalVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { content.localFile?.let { VideoViewInner( @@ -1068,13 +993,10 @@ private fun RenderImageOrVideo( } @OptIn(ExperimentalCoilApi::class) -private fun verifyHash( - content: ZoomableUrlContent, - context: Context, -): Boolean? { +private suspend fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null - context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> + Amethyst.instance.coilCache.openSnapshot(content.url)?.use { snapshot -> val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey() Log.d("Image Hash Verification", "$hash == ${content.hash}") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt index 0af660d01..f9f4de701 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt index d6ea8cad1..351a7347d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 6a5ad0238..0e69e94b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 6776068f9..112c59f0f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 72a58bfb6..0ce003ce4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index a09f841fa..2e20f0483 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt index 9cfa7bf8c..767d31f51 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -33,7 +33,7 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) : } override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.noteListCache)) } override fun applyFilter(collection: Set): Set { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt index 5c902730b..2d4d7302d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt index ff6065c8c..e4e32df9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index 825878aa2..0b8fc858c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt index ebbcc996d..7cb58d8b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt index 267a07e97..1f59fd1b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index a247c4f42..0bb8ca70a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index 479242948..65d249e5a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -36,7 +36,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil } override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.noteListCache)) } override fun applyFilter(collection: Set): Set { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index f0be67cb9..4eaa3778f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -36,7 +36,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil } override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.noteListCache)) } override fun applyFilter(collection: Set): Set { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt index ed6f508df..58fedc1d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.dal +import android.util.Log import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import kotlinx.coroutines.CancellationException class HiddenAccountsFeedFilter(val account: Account) : FeedFilter() { override fun feedKey(): String { @@ -34,7 +36,15 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter() { } override fun feed(): List { - return account.flowHiddenUsers.value.hiddenUsers.map { LocalCache.getOrCreateUser(it) } + return account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull { + try { + LocalCache.getOrCreateUser(it) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("HiddenAccountsFeedFilter", "Failed to parse key $it") + null + } + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 6cc46044e..15d977db7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -45,7 +45,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter { - return sort(innerApplyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.noteListCache)) } override fun applyFilter(collection: Set): Set { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index fe83e7364..ac87c3507 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -50,7 +50,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() } override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values, true) + val notes = innerApplyFilter(LocalCache.noteListCache, true) val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false) return sort(notes + longFormNotes) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 451cf33de..a92a0d4a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -52,7 +52,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() } override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.noteListCache)) } override fun applyFilter(collection: Set): Set { @@ -69,7 +69,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() val loggedInUserHex = loggedInUser.pubkeyHex return collection - .filter { + .filterTo(HashSet()) { it.event !is ChannelCreateEvent && it.event !is ChannelMetadataEvent && it.event !is LnZapRequestEvent && @@ -82,7 +82,6 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() (isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && tagsAnEventByUser(it, loggedInUserHex) } - .toSet() } override fun sort(collection: Set): List { @@ -96,17 +95,12 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() val event = note.event if (event is BaseTextNoteEvent) { - val isAuthoredPostCited = - event.findCitations().any { - LocalCache.notes[it]?.author?.pubkeyHex == authorHex || - LocalCache.addressables[it]?.author?.pubkeyHex == authorHex - } - - return isAuthoredPostCited || - ( - event.citedUsers().contains(authorHex) || - note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true - ) + if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true + + val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } + val isAuthorDirectlyCited = event.citedUsers().contains(authorHex) + + return isAuthoredPostCited || isAuthorDirectlyCited } if (event is ReactionEvent) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index ff5a663ab..a4655eed7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt index 2bf886287..44c93d7a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt index 8004bc44a..075d8a4be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index bea08fe34..81953dd82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -36,7 +36,7 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) : } override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.noteListCache)) } override fun applyFilter(collection: Set): Set { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt index 72e8b3994..616be35e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,7 +30,7 @@ class UserProfileFollowersFeedFilter(val user: User, val account: Account) : Fee } override fun feed(): List { - return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) } + return LocalCache.userListCache.filter { it.isFollowing(user) && !account.isHidden(it) } } override fun limit() = 400 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt index 2d6472ace..9ec0cad68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 1ff82dff1..9acff0d9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -41,7 +41,7 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : } override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values) + val notes = innerApplyFilter(LocalCache.noteListCache) val longFormNotes = innerApplyFilter(LocalCache.addressables.values) return sort(notes + longFormNotes) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index ee703b87e..96b718549 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index cbc459f6a..075f03f2d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 321c3417f..17f3dc24e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -43,7 +43,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter() { } override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values) + val notes = innerApplyFilter(LocalCache.noteListCache) return sort(notes) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt index 6618f95ad..e656751b1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt index 3db228e20..0685e1090 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt index a434db446..cd9f71574 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt index 5705eb2e7..184ebe35c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt index f8258f7df..2c7520103 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -184,6 +184,7 @@ class AddBountyAmountViewModel : ViewModel() { replyingTo = null, root = null, directMentions = setOf(), + forkedFrom = null, ) nextAmount = TextFieldValue("") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt index c102b88b6..e185ab24f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt index 68d34b904..ecdf5ad57 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -49,15 +49,32 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.quartz.events.EventInterface +import com.vitorpamplona.quartz.events.ZapSplitSetup @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayZapSplits( noteEvent: EventInterface, + useAuthorIfEmpty: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val list = remember(noteEvent) { noteEvent.zapSplitSetup() } + val list = + remember(noteEvent) { + val list = noteEvent.zapSplitSetup() + if (list.isEmpty() && useAuthorIfEmpty) { + listOf( + ZapSplitSetup( + lnAddressOrPubKeyHex = noteEvent.pubKey(), + relay = null, + weight = 1.0, + isLnAddress = false, + ), + ) + } else { + list + } + } if (list.isEmpty()) return Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index b7c1280c8..d40a92afe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt index 31477f48c..9df366786 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt index c0e3abd19..4539b0d8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 61b3bd706..e7f17c92a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -77,8 +77,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.quartz.encoders.decodePublicKey -import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -148,12 +147,9 @@ fun DisplayAccount( ) { var baseUser by remember { mutableStateOf( - LocalCache.getUserIfExists( - decodePublicKey( - acc.npub, - ) - .toHexKey(), - ), + decodePublicKeyAsHexOrNull(acc.npub)?.let { + LocalCache.getUserIfExists(it) + }, ) } @@ -161,12 +157,8 @@ fun DisplayAccount( LaunchedEffect(key1 = acc.npub) { launch(Dispatchers.IO) { baseUser = - try { - LocalCache.getOrCreateUser( - decodePublicKey(acc.npub).toHexKey(), - ) - } catch (e: Exception) { - null + decodePublicKeyAsHexOrNull(acc.npub)?.let { + LocalCache.getOrCreateUser(it) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index e2f173a71..102a143eb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,13 +23,10 @@ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect import android.view.View import android.view.ViewTreeObserver -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -45,10 +42,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry @@ -57,8 +54,8 @@ import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Size0dp -import com.vitorpamplona.amethyst.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.bottomIconModifier import kotlinx.collections.immutable.persistentListOf val bottomNavigationItems = @@ -180,7 +177,7 @@ private fun NotifiableIcon( Box(route.notifSize) { Icon( painter = painterResource(id = route.icon), - contentDescription = null, + contentDescription = stringResource(route.contentDescriptor), modifier = route.iconSize, tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, ) @@ -206,9 +203,7 @@ fun AddNotifIconIfNeeded( private fun NotificationDotIcon(modifier: Modifier) { Box(modifier.size(Size10dp)) { Box( - modifier = - remember { Size10Modifier.clip(shape = CircleShape) } - .background(MaterialTheme.colorScheme.primary), + modifier = MaterialTheme.colorScheme.bottomIconModifier, contentAlignment = Alignment.TopEnd, ) { Text( @@ -216,7 +211,7 @@ private fun NotificationDotIcon(modifier: Modifier) { color = Color.White, textAlign = TextAlign.Center, fontSize = Font12SP, - modifier = remember { Modifier.wrapContentHeight().align(Alignment.TopEnd) }, + // modifier = Modifier.wrapContentHeight().align(Alignment.TopEnd), ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ddc18b822..69a478645 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -39,6 +39,7 @@ import androidx.core.util.Consumer import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel @@ -74,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen import com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.net.URLDecoder @Composable fun AppNavigation( @@ -288,9 +290,13 @@ fun AppNavigation( route.route, route.arguments, content = { + val decodedMessage = + it.arguments?.getString("message")?.let { + URLDecoder.decode(it, "utf-8") + } ChatroomScreen( roomId = it.arguments?.getString("id"), - draftMessage = it.arguments?.getString("message"), + draftMessage = decodedMessage, accountViewModel = accountViewModel, nav = nav, ) @@ -354,31 +360,60 @@ fun AppNavigation( } val activity = LocalContext.current.getActivity() - var actionableNextPage by remember { - mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null })) + + var currentIntentNextPage by remember { + mutableStateOf(activity.intent?.data?.toString()?.ifBlank { null }) } - actionableNextPage?.let { - LaunchedEffect(it) { - navController.navigate(it) { - popUpTo(Route.Home.route) - launchSingleTop = true + + currentIntentNextPage?.let { intentNextPage -> + var actionableNextPage by remember { + mutableStateOf(uriToRoute(intentNextPage)) + } + + LaunchedEffect(intentNextPage) { + if (actionableNextPage != null) { + actionableNextPage?.let { + navController.navigate(it) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + actionableNextPage = null + } + } else { + accountViewModel.toast( + R.string.invalid_nip19_uri, + R.string.invalid_nip19_uri_description, + intentNextPage, + ) } + + currentIntentNextPage = null } - actionableNextPage = null } DisposableEffect(activity) { val consumer = Consumer { intent -> val uri = intent?.data?.toString() - val newPage = uriToRoute(uri) - - newPage?.let { route -> - val currentRoute = getRouteWithArguments(navController) - if (!isSameRoute(currentRoute, route)) { - navController.navigate(route) { - popUpTo(Route.Home.route) - launchSingleTop = true + if (!uri.isNullOrBlank()) { + val newPage = uriToRoute(uri) + + if (newPage != null) { + val currentRoute = getRouteWithArguments(navController) + if (!isSameRoute(currentRoute, newPage)) { + navController.navigate(newPage) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + } + } else { + scope.launch { + delay(1000) + accountViewModel.toast( + R.string.invalid_nip19_uri, + R.string.invalid_nip19_uri_description, + uri, + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 825258813..c5dfcf639 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -542,23 +542,17 @@ private fun LoggedInUserPictureDrawer( ) { val profilePicture by accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState() - val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex } - - val automaticallyShowProfilePicture = - remember { - accountViewModel.settings.showProfilePictures.value - } IconButton( onClick = onClick, ) { RobohashFallbackAsyncImage( - robot = pubkeyHex, + robot = accountViewModel.userProfile().pubkeyHex, model = profilePicture, - contentDescription = stringResource(id = R.string.profile_image), + contentDescription = stringResource(id = R.string.your_profile_image), modifier = HeaderPictureModifier, contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, ) } } @@ -985,11 +979,11 @@ fun debugState(context: Context) { Log.d( "STATE DUMP", "Notes: " + - LocalCache.notes.filter { it.value.liveSet != null }.size + + LocalCache.noteListCache.filter { it.liveSet != null }.size + " / " + - LocalCache.notes.filter { it.value.event != null }.size + + LocalCache.noteListCache.filter { it.event != null }.size + " / " + - LocalCache.notes.size, + LocalCache.noteListCache.size, ) Log.d( "STATE DUMP", @@ -1003,21 +997,21 @@ fun debugState(context: Context) { Log.d( "STATE DUMP", "Users: " + - LocalCache.users.filter { it.value.liveSet != null }.size + + LocalCache.userListCache.filter { it.liveSet != null }.size + " / " + - LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + + LocalCache.userListCache.filter { it.info?.latestMetadata != null }.size + " / " + - LocalCache.users.size, + LocalCache.userListCache.size, ) Log.d( "STATE DUMP", "Memory used by Events: " + - LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + + LocalCache.noteListCache.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB", ) - LocalCache.notes.values + LocalCache.noteListCache .groupBy { it.event?.kind() } .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } LocalCache.addressables.values diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index c01291854..d61df96f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index 8b2150edc..e27c3d5be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -75,7 +75,8 @@ fun routeToMessage( val withKey = ChatroomKey(persistentSetOf(user)) accountViewModel.account.userProfile().createChatroom(withKey) return if (draftMessage != null) { - "Room/${withKey.hashCode()}?message=$draftMessage" + val encodedMessage = URLEncoder.encode(draftMessage, "utf-8") + "Room/${withKey.hashCode()}?message=$encodedMessage" } else { "Room/${withKey.hashCode()}" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index f33b457de..ecdc18bf2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -60,6 +60,7 @@ sealed class Route( val icon: Int, val notifSize: Modifier = Modifier.size(Size23dp), val iconSize: Modifier = Modifier.size(Size20dp), + val contentDescriptor: Int = R.string.route, val hasNewItems: (Account, Set) -> Boolean = { _, _ -> false }, @@ -80,6 +81,7 @@ sealed class Route( }, ) .toImmutableList(), + contentDescriptor = R.string.route_home, hasNewItems = { accountViewModel, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, newNotes) }, @@ -89,27 +91,31 @@ sealed class Route( Route( route = "Global", icon = R.drawable.ic_globe, + contentDescriptor = R.string.route_global, ) object Search : Route( route = "Search", icon = R.drawable.ic_search, + contentDescriptor = R.string.route_search, ) object Video : Route( route = "Video", icon = R.drawable.ic_video, + contentDescriptor = R.string.route_video, ) object Discover : Route( route = "Discover", icon = R.drawable.ic_sensors, - hasNewItems = { accountViewModel, newNotes -> - DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) - }, + // hasNewItems = { accountViewModel, newNotes -> + // DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) + // }, + contentDescriptor = R.string.route_discover, ) object Notification : @@ -119,6 +125,7 @@ sealed class Route( hasNewItems = { accountViewModel, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, newNotes) }, + contentDescriptor = R.string.route_notifications, ) object Message : @@ -128,18 +135,21 @@ sealed class Route( hasNewItems = { accountViewModel, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, newNotes) }, + contentDescriptor = R.string.route_messages, ) object BlockedUsers : Route( route = "BlockedUsers", icon = R.drawable.ic_security, + contentDescriptor = R.string.route_security_filters, ) object Bookmarks : Route( route = "Bookmarks", icon = R.drawable.ic_bookmarks, + contentDescriptor = R.string.route_home, ) object Profile : diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index a8cc82304..007f5ea66 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.BadgeCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch @@ -162,7 +163,7 @@ fun BadgeCompose( Icon( imageVector = Icons.Default.MoreVert, null, - modifier = Modifier.size(15.dp), + modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index dd0ea16ee..81823b0c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -74,7 +75,7 @@ fun BlankNote( } if (!showDivider) { - Divider( + HorizontalDivider( modifier = Modifier.padding(vertical = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index a12e5a2e6..e1c59eede 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index 2a8689102..689e10414 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 842f65f86..4d647c181 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -78,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size15Modifier @@ -491,8 +493,14 @@ private fun RenderReply( onWantsToReply: (Note) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { - val replyTo by remember { derivedStateOf { note.replyTo?.lastOrNull() } } - replyTo?.let { note -> + val replyTo = + produceState(initialValue = note.replyTo?.lastOrNull()) { + accountViewModel.unwrapIfNeeded(value) { + value = it + } + } + + replyTo.value?.let { note -> ChatroomMessageCompose( note, null, @@ -619,19 +627,18 @@ private fun RenderRegularTextNote( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val modifier = remember { Modifier.padding(top = 5.dp) } - LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent -> if (eventContent != null) { SensitivityWarning( note = note, accountViewModel = accountViewModel, ) { + val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + TranslatableRichTextViewer( - content = eventContent!!, + content = eventContent, canPreview = canPreview, - modifier = modifier, + modifier = HalfTopPadding, tags = tags, backgroundColor = backgroundBubbleColor, accountViewModel = accountViewModel, @@ -642,8 +649,8 @@ private fun RenderRegularTextNote( TranslatableRichTextViewer( content = stringResource(id = R.string.could_not_decrypt_the_message), canPreview = true, - modifier = modifier, - tags = tags, + modifier = HalfTopPadding, + tags = EmptyTagList, backgroundColor = backgroundBubbleColor, accountViewModel = accountViewModel, nav = nav, @@ -780,7 +787,6 @@ private fun DisplayMessageUsername( Spacer(modifier = StdHorzSpacer) CreateClickableTextWithEmoji( clickablePart = userDisplayName, - suffix = "", maxLines = 1, tags = userTags, fontWeight = FontWeight.Bold, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 514e11796..1e0616bd3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -152,7 +152,7 @@ fun LikeIcon( ) { Icon( painter = painterResource(R.drawable.ic_like), - null, + contentDescription = stringResource(id = R.string.like_description), modifier = iconSizeModifier, tint = grayTint, ) @@ -165,7 +165,7 @@ fun RepostedIcon( ) { Icon( painter = painterResource(R.drawable.ic_retweeted), - null, + contentDescription = stringResource(id = R.string.boost_or_quote_description), modifier = modifier, tint = tint, ) @@ -198,10 +198,11 @@ fun ZappedIcon(modifier: Modifier) { fun ZapIcon( modifier: Modifier, tint: Color = Color.Unspecified, + contentDescriptor: Int = R.string.zap_description, ) { Icon( imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), + contentDescription = stringResource(contentDescriptor), tint = tint, modifier = modifier, ) @@ -244,20 +245,26 @@ fun OpenInNewIcon( } @Composable -fun ExpandLessIcon(modifier: Modifier) { +fun ExpandLessIcon( + modifier: Modifier, + contentDescriptor: Int, +) { Icon( imageVector = Icons.Default.ExpandLess, - null, + contentDescription = stringResource(id = contentDescriptor), modifier = modifier, tint = MaterialTheme.colorScheme.subtleButton, ) } @Composable -fun ExpandMoreIcon(modifier: Modifier) { +fun ExpandMoreIcon( + modifier: Modifier, + contentDescriptor: Int, +) { Icon( imageVector = Icons.Default.ExpandMore, - null, + contentDescription = stringResource(id = contentDescriptor), modifier = modifier, tint = MaterialTheme.colorScheme.subtleButton, ) @@ -270,7 +277,7 @@ fun CommentIcon( ) { Icon( painter = painterResource(R.drawable.ic_comment), - contentDescription = null, + contentDescription = stringResource(id = R.string.reply_description), modifier = iconSizeModifier, tint = tint, ) @@ -293,7 +300,7 @@ fun ViewCountIcon( fun PollIcon() { Icon( painter = painterResource(R.drawable.ic_poll), - null, + contentDescription = stringResource(id = R.string.poll), modifier = Size20Modifier, tint = MaterialTheme.colorScheme.onBackground, ) @@ -303,7 +310,7 @@ fun PollIcon() { fun RegularPostIcon() { Icon( painter = painterResource(R.drawable.ic_lists), - null, + contentDescription = stringResource(id = R.string.disable_poll), modifier = Size20Modifier, tint = MaterialTheme.colorScheme.onBackground, ) @@ -435,10 +442,10 @@ fun LinkIcon( } @Composable -fun VerticalDotsIcon() { +fun VerticalDotsIcon(contentDescriptor: Int? = null) { Icon( imageVector = Icons.Default.MoreVert, - null, + contentDescription = contentDescriptor?.let { stringResource(id = it) }, modifier = Size18Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 08deac29f..3e75719d9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt new file mode 100644 index 000000000..b782a1c19 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt @@ -0,0 +1,123 @@ +/** + * 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.ui.note + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "resourceType", +) +@JsonSubTypes( + JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"), + JsonSubTypes.Type(value = Patient::class, name = "Patient"), + JsonSubTypes.Type(value = Bundle::class, name = "Bundle"), + JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"), +) +open class Resource( + var resourceType: String? = null, + var id: String = "", +) + +class Practitioner( + resourceType: String? = null, + id: String = "", + var active: Boolean? = null, + var name: ArrayList = arrayListOf(), + var gender: String? = null, +) : Resource(resourceType, id) + +class Patient( + resourceType: String? = null, + id: String = "", + var active: Boolean? = null, + var name: ArrayList = arrayListOf(), + var gender: String? = null, +) : Resource(resourceType, id) + +class HumanName( + var use: String? = null, + var family: String? = null, + var given: ArrayList = arrayListOf(), +) { + fun assembleName(): String { + return given.joinToString(" ") + " " + family + } +} + +class Bundle( + resourceType: String? = null, + id: String = "", + var type: String? = null, + var created: String? = null, + var entry: List = arrayListOf(), +) : Resource(resourceType, id) + +class VisionPrescription( + resourceType: String? = null, + id: String = "", + var status: String? = null, + var created: String? = null, + var patient: Reference? = Reference(), + var encounter: Reference? = Reference(), + var dateWritten: String? = null, + var prescriber: Reference? = Reference(), + var lensSpecification: List = arrayListOf(), +) : Resource(resourceType, id) + +class LensSpecification( + var eye: String? = null, + var sphere: Double? = null, + var cylinder: Double? = null, + var axis: Double? = null, + var add: Double? = null, + var prism: List = arrayListOf(), + // contact lenses + var power: Double? = null, + var backCurve: Double? = null, + var diameter: Double? = null, + var color: String? = null, + var brand: String? = null, + var note: String? = null, +) + +class Prism( + var amount: Double? = null, + var base: String? = null, +) + +class Reference( + var reference: String? = null, +) + +fun findReferenceInDb( + it: String, + db: Map, +): Resource? { + val parts = it.split("/") + return if (parts.size == 2) { + db.get(parts[1].removePrefix("#")) + } else { + db.get(it.removePrefix("#")) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 66a84440d..3226d05cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -90,10 +89,10 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.events.EmptyTagList import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.time.ExperimentalTime @@ -232,10 +231,7 @@ fun RenderLikeGallery( val url = noStartColon.substringAfter(":") val renderable = - listOf( - ImageUrlType(url), - ) - .toImmutableList() + persistentListOf(Nip30CustomEmoji.ImageUrlType(url)) InLineIconRenderer( renderable, @@ -542,11 +538,6 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( author: User, accountViewModel: AccountViewModel, ) { - val automaticallyShowProfilePicture = - remember { - accountViewModel.settings.showProfilePictures.value - } - WatchUserMetadata(author) { baseUserPicture -> // Crossfade(targetState = baseUserPicture) { userPicture -> RobohashFallbackAsyncImage( @@ -555,7 +546,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( contentDescription = stringResource(id = R.string.profile_image), modifier = MaterialTheme.colorScheme.profile35dpModifier, contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, ) // } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 78562ba91..913cc66f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index cad8439b5..fbab305e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -48,15 +49,18 @@ import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -65,6 +69,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -79,9 +84,12 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.drawable.toBitmap @@ -92,9 +100,17 @@ import androidx.lifecycle.map import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.request.SuccessResult +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fonfon.kgeohash.GeoHash import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaLocalImage +import com.vitorpamplona.amethyst.commons.MediaLocalVideo +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note @@ -105,6 +121,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewRelayListView import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -113,17 +130,10 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.VideoView -import com.vitorpamplona.amethyst.ui.components.ZoomableContent import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage -import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo -import com.vitorpamplona.amethyst.ui.components.figureOutMimeType -import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth -import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison +import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel import com.vitorpamplona.amethyst.ui.elements.AddButton import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost @@ -177,8 +187,10 @@ import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor +import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.normalNoteModifier import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -203,9 +215,13 @@ import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiUrl import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GitPatchEvent +import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent @@ -227,14 +243,20 @@ import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent +import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.net.URL +import java.text.DecimalFormat +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @@ -1123,7 +1145,7 @@ private fun NoteBody( val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } if (zapSplits && noteEvent != null) { Spacer(modifier = HalfDoubleVertSpacer) - DisplayZapSplits(noteEvent, accountViewModel, nav) + DisplayZapSplits(noteEvent, false, accountViewModel, nav) } } @@ -1162,9 +1184,15 @@ private fun RenderNoteRow( is LongTextNoteEvent -> { RenderLongFormContent(baseNote, accountViewModel, nav) } + is WikiNoteEvent -> { + RenderWikiContent(baseNote, accountViewModel, nav) + } is BadgeAwardEvent -> { RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) } + is FhirResourceEvent -> { + RenderFhirResource(baseNote, accountViewModel, nav) + } is PeopleListEvent -> { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) } @@ -1180,6 +1208,19 @@ private fun RenderNoteRow( is LiveActivitiesEvent -> { RenderLiveActivityEvent(baseNote, accountViewModel, nav) } + is GitRepositoryEvent -> { + RenderGitRepositoryEvent(baseNote, accountViewModel, nav) + } + is GitPatchEvent -> { + RenderGitPatchEvent( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } is PrivateDmEvent -> { RenderPrivateMessage( baseNote, @@ -1447,7 +1488,7 @@ fun RenderAppDefinition( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -1503,7 +1544,7 @@ fun RenderAppDefinition( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -2342,6 +2383,7 @@ private fun RenderReport( ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) + ReportEvent.ReportType.OTHER -> stringResource(R.string.other) } } .toSet() @@ -2494,32 +2536,152 @@ fun SecondUserInfoRow( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val noteEvent = remember { note.event } ?: return - val noteAuthor = remember { note.author } ?: return + val noteEvent = note.event ?: return + val noteAuthor = note.author ?: return Row( verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight, ) { - ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav) + if (noteEvent is BaseTextNoteEvent && noteEvent.isAFork()) { + ShowForkInformation(noteEvent, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav) + } else { + ObserveDisplayNip05Status(noteAuthor, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav) + } - val geo = remember { noteEvent.getGeoHash() } + val geo = remember(noteEvent) { noteEvent.getGeoHash() } if (geo != null) { Spacer(StdHorzSpacer) DisplayLocation(geo, nav) } - val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } + val baseReward = remember(noteEvent) { noteEvent.getReward()?.let { Reward(it) } } if (baseReward != null) { Spacer(StdHorzSpacer) DisplayReward(baseReward, note, accountViewModel, nav) } - val pow = remember { noteEvent.getPoWRank() } + val pow = remember(noteEvent) { noteEvent.getPoWRank() } if (pow > 20) { Spacer(StdHorzSpacer) DisplayPoW(pow) } + + DisplayOts(note, accountViewModel) + } +} + +@Composable +fun DisplayOts( + note: Note, + accountViewModel: AccountViewModel, +) { + LoadOts( + note, + accountViewModel, + whenConfirmed = { unixtimestamp -> + val context = LocalContext.current + val timeStr by remember(unixtimestamp) { mutableStateOf(timeAgoNoDot(unixtimestamp, context = context)) } + + ClickableText( + text = buildAnnotatedString { append(stringResource(id = R.string.existed_since, timeStr)) }, + onClick = { + val fullDateTime = + SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)) + + accountViewModel.toast( + context.getString(R.string.ots_info_title), + context.getString(R.string.ots_info_description, fullDateTime), + ) + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + }, + whenPending = { + Text( + stringResource(id = R.string.timestamp_pending_short), + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + }, + ) +} + +@Composable +private fun ShowForkInformation( + noteEvent: BaseTextNoteEvent, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } + val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() } + if (forkedAddress != null) { + LoadAddressableNote(aTag = forkedAddress, accountViewModel = accountViewModel) { addressableNote -> + if (addressableNote != null) { + ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) + } + } + } else if (forkedEvent != null) { + LoadNote(forkedEvent, accountViewModel = accountViewModel) { event -> + if (event != null) { + ForkInformationRowLightColor(event, modifier, accountViewModel, nav) + } + } + } +} + +@Composable +fun ForkInformationRowLightColor( + originalVersion: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + ClickableText( + text = + buildAnnotatedString { + append(stringResource(id = R.string.forked_from)) + append(" ") + }, + onClick = { nav(route) }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) + + val userState by author.live().metadata.observeAsState() + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + maxLines = 1, + route = route, + overrideColor = MaterialTheme.colorScheme.nip05, + fontSize = Font14SP, + nav = nav, + tags = userTags, + ) + } + } } } @@ -2544,6 +2706,38 @@ fun LoadStatuses( content(statuses) } +@Composable +fun LoadOts( + note: Note, + accountViewModel: AccountViewModel, + whenConfirmed: @Composable (Long) -> Unit, + whenPending: @Composable () -> Unit, +) { + var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } + + val noteStatus by note.live().innerOts.observeAsState() + + LaunchedEffect(key1 = noteStatus) { + accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> + earliestDate = + if (newOts == null) { + GenericLoadable.Empty() + } else { + GenericLoadable.Loaded(newOts) + } + } + } + + (earliestDate as? GenericLoadable.Loaded)?.let { + whenConfirmed(it.loaded) + } ?: run { + val account = accountViewModel.account.saveable.observeAsState() + if (account.value?.account?.hasPendingAttestations(note) == true) { + whenPending() + } + } +} + @Composable fun LoadCityName( geohash: GeoHash, @@ -2658,7 +2852,7 @@ fun MoreOptionsButton( modifier = Size24Modifier, onClick = enablePopup, ) { - VerticalDotsIcon() + VerticalDotsIcon(R.string.note_options) NoteDropDownMenu( baseNote, @@ -2918,12 +3112,19 @@ private fun LoadAndDisplayUrl(url: String) { } @Composable -private fun LoadAndDisplayUser( +fun LoadAndDisplayUser( userBase: User, nav: (String) -> Unit, ) { - val route = remember { "User/${userBase.pubkeyHex}" } + LoadAndDisplayUser(userBase, "User/${userBase.pubkeyHex}", nav) +} +@Composable +fun LoadAndDisplayUser( + userBase: User, + route: String, + nav: (String) -> Unit, +) { val userState by userBase.live().metadata.observeAsState() val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } val userTags = @@ -2932,7 +3133,6 @@ private fun LoadAndDisplayUser( if (userDisplayName != null) { CreateClickableTextWithEmoji( clickablePart = userDisplayName, - suffix = " ", maxLines = 1, route = route, nav = nav, @@ -3064,15 +3264,12 @@ fun FileHeaderDisplay( val hash = event.hash() val dimensions = event.dimensions() val description = event.alt() ?: event.content - val isImage = - imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) - } + val isImage = RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() - mutableStateOf( + mutableStateOf( if (isImage) { - ZoomableUrlImage( + MediaUrlImage( url = fullUrl, description = description, hash = hash, @@ -3081,7 +3278,7 @@ fun FileHeaderDisplay( uri = uri, ) } else { - ZoomableUrlVideo( + MediaUrlVideo( url = fullUrl, description = description, hash = hash, @@ -3126,15 +3323,12 @@ fun VideoDisplay( val hash = event.hash() val dimensions = event.dimensions() val description = event.alt() ?: event.content - val isImage = - imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) - } + val isImage = RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() - mutableStateOf( + mutableStateOf( if (isImage) { - ZoomableUrlImage( + MediaUrlImage( url = fullUrl, description = description, hash = hash, @@ -3143,7 +3337,7 @@ fun VideoDisplay( uri = uri, ) } else { - ZoomableUrlVideo( + MediaUrlVideo( url = fullUrl, description = description, hash = hash, @@ -3273,17 +3467,17 @@ private fun ObserverAndRenderNIP95( val newContent = if (mimeType?.startsWith("image") == true) { - ZoomableLocalImage( + MediaLocalImage( localFile = localDir, mimeType = mimeType, description = description, - blurhash = blurHash, dim = dimensions, + blurhash = blurHash, isVerified = true, uri = uri, ) } else { - ZoomableLocalVideo( + MediaLocalVideo( localFile = localDir, mimeType = mimeType, description = description, @@ -3294,7 +3488,7 @@ private fun ObserverAndRenderNIP95( ) } - mutableStateOf(newContent) + mutableStateOf(newContent) } Crossfade(targetState = content) { @@ -3462,6 +3656,211 @@ fun AudioHeader( } } +@Composable +fun RenderGitPatchEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitPatchEvent ?: return + + RenderGitPatchEvent(event, baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) +} + +@Composable +private fun RenderShortRepositoryHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState = baseNote.live().metadata.observeAsState() + val note = remember(noteState) { noteState.value?.note } ?: return + val noteEvent = note.event as? GitRepositoryEvent ?: return + + Column( + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + ) { + val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + Text( + text = stringResource(id = R.string.git_repository, title), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + noteEvent.description()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RenderGitPatchEvent( + noteEvent: GitPatchEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val repository = remember(noteEvent) { noteEvent.repository() } + + if (repository != null) { + LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) + Spacer(modifier = DoubleVertSpacer) + } + } + } + + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } + + val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} + +@Composable +fun RenderGitRepositoryEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitRepositoryEvent ?: return + + RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav) +} + +@Composable +private fun RenderGitRepositoryEvent( + noteEvent: GitRepositoryEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + val summary = remember(noteEvent) { noteEvent.description() } + val web = remember(noteEvent) { noteEvent.web() } + val clone = remember(noteEvent) { noteEvent.clone() } + + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).padding(Size10dp), + ) { + Column { + Text( + text = stringResource(id = R.string.git_repository, title), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + summary?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + web?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringResource(id = R.string.git_web_address), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + + clone?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringResource(id = R.string.git_clone_address), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + } + } +} + @Composable fun RenderLiveActivityEvent( baseNote: Note, @@ -3669,6 +4068,91 @@ private fun LongFormHeader( } summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun RenderWikiContent( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? WikiNoteEvent ?: return + + WikiNoteHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +private fun WikiNoteHeader( + noteEvent: WikiNoteEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val image = remember(noteEvent) { noteEvent.image() } + + Row( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: CreateImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Spacer(modifier = StdVertSpacer) Text( text = it, style = MaterialTheme.typography.bodySmall, @@ -3879,3 +4363,223 @@ fun CreateImageHeader( } } } + +@Preview +@Composable +fun RenderEyeGlassesPrescriptionPreview() { + val accountViewModel = mockAccountViewModel() + val nav: (String) -> Unit = {} + + val prescriptionEvent = Event.fromJson("{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}") as FhirResourceEvent + + RenderFhirResource(prescriptionEvent, accountViewModel, nav) +} + +@Composable +fun RenderFhirResource( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? FhirResourceEvent ?: return + + RenderFhirResource(event, accountViewModel, nav) +} + +@Composable +fun RenderFhirResource( + event: FhirResourceEvent, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var baseResource: Resource? by remember(event) { + mutableStateOf(null) + } + + LaunchedEffect(key1 = event) { + withContext(Dispatchers.IO) { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + try { + baseResource = mapper.readValue(event.content, Resource::class.java) + } catch (e: Exception) { + Log.e("RenderEyeGlassesPrescription", "Parser error", e) + } + } + } + + baseResource?.let { resource -> + when (resource) { + is Bundle -> { + val db = resource.entry.associate { it.id to it } + val vision = resource.entry.filterIsInstance(VisionPrescription::class.java) + + vision.firstOrNull()?.let { + RenderEyeGlassesPrescription(it, db, accountViewModel, nav) + } + } + is VisionPrescription -> { + val db = mapOf(resource.id to resource) + RenderEyeGlassesPrescription(resource, db, accountViewModel, nav) + } + else -> { + } + } + } +} + +@Composable +fun RenderEyeGlassesPrescription( + visionPrescription: VisionPrescription, + db: Map, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(Size10dp), + ) { + val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" } + val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" } + + Text( + "Eyeglasses Prescription", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(StdVertSpacer) + + visionPrescription.patient?.reference?.let { + val patient = findReferenceInDb(it, db) as? Patient + + patient?.name?.firstOrNull()?.assembleName()?.let { + Text( + text = "Patient: $it", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + ) + } + } + visionPrescription.status?.let { + Text( + text = "Status: ${it.capitalize()}", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + ) + } + + Spacer(DoubleVertSpacer) + + RenderEyeGlassesPrescriptionHeaderRow() + HorizontalDivider(thickness = DividerThickness) + + rightEye?.let { + RenderEyeGlassesPrescriptionRow(data = it) + HorizontalDivider(thickness = DividerThickness) + } + + leftEye?.let { + RenderEyeGlassesPrescriptionRow(data = it) + HorizontalDivider(thickness = DividerThickness) + } + + Spacer(DoubleVertSpacer) + + visionPrescription.prescriber?.reference?.let { + val practitioner = findReferenceInDb(it, db) as? Practitioner + + practitioner?.name?.firstOrNull()?.assembleName()?.let { + Text( + text = "Signed by: $it", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + textAlign = TextAlign.Right, + ) + } + } + } +} + +@Composable +fun RenderEyeGlassesPrescriptionHeaderRow() { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Eye", + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = "Sph", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = "Cyl", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = "Axis", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = "Add", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + } +} + +@Composable +fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val numberFormat = DecimalFormat("##.00") + val integerFormat = DecimalFormat("###") + + Text( + text = data.eye?.capitalize() ?: "Unknown", + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = formatOrBlank(data.sphere, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = formatOrBlank(data.cylinder, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = formatOrBlank(data.axis, integerFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = formatOrBlank(data.add, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + } +} + +fun formatOrBlank( + amount: Double?, + numberFormat: NumberFormat, +): String { + if (amount == null) return "" + if (Math.abs(amount) < 0.01) return "" + return numberFormat.format(amount) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 8cb89cf64..d2bbb7f59 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -342,11 +342,9 @@ private fun RenderMainPopup( icon = ImageVector.vectorResource(id = R.drawable.relays), label = stringResource(R.string.broadcast), ) { - scope.launch(Dispatchers.IO) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() - } + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() } VerticalDivider(primaryLight) NoteQuickActionItem( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index bd604d56f..e40d7ac40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index fa9fceae8..0fb6008fa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -209,7 +209,7 @@ class PollNoteViewModel : ViewModel() { return false } - fun isPollOptionZappedBy( + suspend fun isPollOptionZappedBy( option: Int, user: User, onWasZappedByAuthor: () -> Unit, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index 98e25dc39..1ef039515 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index dfee68fc7..794a20752 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -99,9 +99,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.NewPostView -import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.components.TextType import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -128,6 +126,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji +import com.vitorpamplona.quartz.events.BaseTextNoteEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -379,9 +379,9 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat label = "RenderShowIndividualReactionsButton", ) { if (it) { - ExpandLessIcon(modifier = Size22Modifier) + ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post) } else { - ExpandMoreIcon(modifier = Size22Modifier) + ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post) } } } @@ -500,6 +500,7 @@ private fun BoostWithDialog( nav: (String) -> Unit, ) { var wantsToQuote by remember { mutableStateOf(null) } + var wantsToFork by remember { mutableStateOf(null) } if (wantsToQuote != null) { NewPostView( @@ -511,7 +512,34 @@ private fun BoostWithDialog( ) } - BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote } + if (wantsToFork != null) { + val replyTo = + remember(wantsToFork) { + val forkEvent = wantsToFork?.event + if (forkEvent is BaseTextNoteEvent) { + val hex = forkEvent.replyingTo() + wantsToFork?.replyTo?.filter { it.event?.id() == hex }?.firstOrNull() + } else { + null + } + } + + NewPostView( + onClose = { wantsToFork = null }, + baseReplyTo = replyTo, + fork = wantsToFork, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + BoostReaction( + baseNote, + grayTint, + accountViewModel, + onQuotePress = { wantsToQuote = baseNote }, + onForkPress = { wantsToFork = baseNote }, + ) } @Composable @@ -625,7 +653,7 @@ fun TextCount( } @Composable -private fun SlidingAnimationAmount( +fun SlidingAnimationAmount( amount: MutableState, textColor: Color, ) { @@ -651,6 +679,7 @@ fun BoostReaction( iconSizeModifier: Modifier = Size20Modifier, iconSize: Dp = Size20dp, onQuotePress: () -> Unit, + onForkPress: () -> Unit, ) { var wantsToBoost by remember { mutableStateOf(false) } @@ -672,7 +701,13 @@ fun BoostReaction( wantsToBoost = false onQuotePress() }, - onRepost = { accountViewModel.boost(baseNote) }, + onRepost = { + accountViewModel.boost(baseNote) + }, + onFork = { + wantsToBoost = false + onForkPress() + }, ) } } @@ -808,10 +843,9 @@ private fun RenderReactionType( if (reactionType.isNotEmpty() && reactionType[0] == ':') { val renderable = remember(reactionType) { - listOf( - ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), + persistentListOf( + Nip30CustomEmoji.ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), ) - .toImmutableList() } InLineIconRenderer( @@ -1028,7 +1062,7 @@ fun ZapReaction( } } -private fun zapClick( +fun zapClick( baseNote: Note, accountViewModel: AccountViewModel, context: Context, @@ -1065,7 +1099,7 @@ private fun zapClick( } @Composable -private fun ObserveZapIcon( +fun ObserveZapIcon( baseNote: Note, accountViewModel: AccountViewModel, inner: @Composable (MutableState) -> Unit, @@ -1088,7 +1122,7 @@ private fun ObserveZapIcon( } @Composable -private fun ObserveZapAmountText( +fun ObserveZapAmountText( baseNote: Note, accountViewModel: AccountViewModel, inner: @Composable (MutableState) -> Unit, @@ -1151,6 +1185,7 @@ private fun BoostTypeChoicePopup( onDismiss: () -> Unit, onQuote: () -> Unit, onRepost: () -> Unit, + onFork: () -> Unit, ) { val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } @@ -1191,6 +1226,18 @@ private fun BoostTypeChoicePopup( ) { Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onFork, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.fork), color = Color.White, textAlign = TextAlign.Center) + } } } } @@ -1284,11 +1331,10 @@ private fun ActionableReactionButton( val url = noStartColon.substringAfter(":") val renderable = - listOf( - ImageUrlType(url), - TextType(removeSymbol), + persistentListOf( + Nip30CustomEmoji.ImageUrlType(url), + Nip30CustomEmoji.TextType(removeSymbol), ) - .toImmutableList() InLineIconRenderer( renderable, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index ec9d50417..55a1a16e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 3fdc8e644..71c661083 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -37,6 +37,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -87,7 +89,7 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) { ) { Icon( imageVector = Icons.Default.ExpandMore, - null, + contentDescription = stringResource(id = R.string.expand_relay_list), modifier = ShowMoreRelaysButtonIconModifier, tint = MaterialTheme.colorScheme.placeholderText, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 9891828db..cac24a089 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -39,6 +39,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -51,7 +52,7 @@ import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.RelayBriefInfoCache -import com.vitorpamplona.amethyst.model.RelayInformation +import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -119,7 +120,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) { ) { Icon( imageVector = Icons.Default.ChevronRight, - null, + contentDescription = stringResource(id = R.string.expand_relay_list), modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) @@ -132,11 +133,27 @@ fun RenderRelay( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - var relayInfo: RelayInformation? by remember { mutableStateOf(null) } + val relayInfo by + produceState( + initialValue = Nip11CachedRetriever.getFromCache(relay.url), + ) { + if (value == null) { + accountViewModel.retrieveRelayDocument( + relay.url, + onInfo = { + value = it + }, + onError = { url, errorCode, exceptionMessage -> + }, + ) + } + } + + var openRelayDialog by remember { mutableStateOf(false) } - if (relayInfo != null) { + if (openRelayDialog && relayInfo != null) { RelayInformationDialog( - onClose = { relayInfo = null }, + onClose = { openRelayDialog = false }, relayInfo = relayInfo!!, relayBriefInfo = relay, accountViewModel = accountViewModel, @@ -149,14 +166,10 @@ fun RenderRelay( val interactionSource = remember { MutableInteractionSource() } val ripple = rememberRipple(bounded = false, radius = Size15dp) - val automaticallyShowProfilePicture = - remember { - accountViewModel.settings.showProfilePictures.value - } - val clickableModifier = remember(relay) { - Modifier.padding(1.dp) + Modifier + .padding(1.dp) .size(Size15dp) .clickable( role = Role.Button, @@ -165,7 +178,9 @@ fun RenderRelay( onClick = { accountViewModel.retrieveRelayDocument( relay.url, - onInfo = { relayInfo = it }, + onInfo = { + openRelayDialog = true + }, onError = { url, errorCode, exceptionMessage -> val msg = when (errorCode) { @@ -175,18 +190,21 @@ fun RenderRelay( url, exceptionMessage, ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString( R.string.relay_information_document_error_assemble_url, url, exceptionMessage, ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString( R.string.relay_information_document_error_assemble_url, url, exceptionMessage, ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString( R.string.relay_information_document_error_assemble_url, @@ -209,21 +227,25 @@ fun RenderRelay( modifier = clickableModifier, contentAlignment = Alignment.Center, ) { - RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture) + RenderRelayIcon( + displayUrl = relay.displayUrl, + iconUrl = relayInfo?.icon ?: relay.favIcon, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + ) } } @Composable fun RenderRelayIcon( displayUrl: String, - iconUrl: String, + iconUrl: String?, loadProfilePicture: Boolean, iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, ) { RobohashFallbackAsyncImage( robot = displayUrl, model = iconUrl, - contentDescription = stringResource(id = R.string.relay_icon), + contentDescription = stringResource(id = R.string.relay_info, displayUrl), colorFilter = RelayIconFilter, modifier = iconModifier, loadProfilePicture = loadProfilePicture, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index f3c095cea..2be4a0770 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index a415a0768..d2040b21a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -71,6 +71,46 @@ fun timeAgo( } } +fun timeAgoNoDot( + time: Long?, + context: Context, +): String { + if (time == null) return " " + if (time == 0L) return " ${context.getString(R.string.never)}" + + val timeDifference = TimeUtils.now() - time + + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 + + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat("MMM dd", locale) + } + + yearFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_MONTH) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat("MMM dd", locale) + } + + monthFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // 2 days + (timeDifference / TimeUtils.ONE_DAY).toString() + context.getString(R.string.d) + } else if (timeDifference > TimeUtils.ONE_HOUR) { + (timeDifference / TimeUtils.ONE_HOUR).toString() + context.getString(R.string.h) + } else if (timeDifference > TimeUtils.ONE_MINUTE) { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + context.getString(R.string.m) + } else { + context.getString(R.string.now) + } +} + fun timeAgoShort( mills: Long?, stringForNow: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index cf07e8e91..b6861152c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -80,17 +80,17 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton -import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.components.TextType import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiUrl import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -283,11 +283,10 @@ private fun RenderReactionOption( val url = noStartColon.substringAfter(":") val renderable = - listOf( - ImageUrlType(url), - TextType(" ✖"), + persistentListOf( + Nip30CustomEmoji.ImageUrlType(url), + Nip30CustomEmoji.TextType(" ✖"), ) - .toImmutableList() InLineIconRenderer( renderable, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index bcd0ce310..e81d9bee4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -87,7 +87,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Nip47URI import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner @@ -99,10 +98,13 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.encoders.Nip47WalletConnect +import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.decodePublicKey import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.LnZapEvent import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException class UpdateZapAmountViewModel(val account: Account) : ViewModel() { var nextAmount by mutableStateOf(TextFieldValue("")) @@ -149,6 +151,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() { try { decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() } catch (e: Exception) { + if (e is CancellationException) throw e null } @@ -163,17 +166,11 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() { addedWSS } - val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } - val privKeyHex = - try { - unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } - } catch (e: Exception) { - null - } + val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } if (pubkeyHex != null) { account?.changeZapPaymentRequest( - Nip47URI( + Nip47WalletConnect.Nip47URI( pubkeyHex, relayUrl, privKeyHex, @@ -204,7 +201,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() { } fun updateNIP47(uri: String) { - val contact = Nip47WalletConnectParser.parse(uri) + val contact = Nip47WalletConnect.parse(uri) if (contact != null) { walletConnectPubkey = TextFieldValue(contact.pubKeyHex) walletConnectRelay = TextFieldValue(contact.relayUri ?: "") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 0622fadd0..d808056d3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index ac9efe810..b49ef2786 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -340,10 +340,11 @@ fun BaseUserPicture( val myIconSize by remember(size) { derivedStateOf { size.div(3.5f) } } Box(outerModifier, contentAlignment = Alignment.TopEnd) { - LoadUserProfilePicture(baseUser) { userProfilePicture -> + LoadUserProfilePicture(baseUser) { userProfilePicture, userName -> InnerUserPicture( userHex = baseUser.pubkeyHex, userPicture = userProfilePicture, + userName = userName, size = size, modifier = innerModifier, accountViewModel = accountViewModel, @@ -357,17 +358,18 @@ fun BaseUserPicture( @Composable fun LoadUserProfilePicture( baseUser: User, - innerContent: @Composable (String?) -> Unit, + innerContent: @Composable (String?, String?) -> Unit, ) { - val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture()) + val userProfile by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - innerContent(userProfile) + innerContent(userProfile?.profilePicture(), userProfile?.bestDisplayName() ?: userProfile?.bestDisplayName()) } @Composable fun InnerUserPicture( userHex: String, userPicture: String?, + userName: String?, size: Dp, modifier: Modifier, accountViewModel: AccountViewModel, @@ -386,7 +388,12 @@ fun InnerUserPicture( RobohashFallbackAsyncImage( robot = userHex, model = userPicture, - contentDescription = stringResource(id = R.string.profile_image), + contentDescription = + if (userName != null) { + stringResource(id = R.string.profile_image_of_user, userName) + } else { + stringResource(id = R.string.profile_image) + }, modifier = myImageModifier, contentScale = ContentScale.Crop, loadProfilePicture = automaticallyShowProfilePicture, @@ -547,13 +554,28 @@ fun NoteDropDownMenu( DropdownMenuItem( text = { Text(stringResource(R.string.broadcast)) }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.broadcast(note) - onDismiss() - } + accountViewModel.broadcast(note) + onDismiss() }, ) Divider() + if (accountViewModel.account.hasPendingAttestations(note)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.timestamp_pending)) }, + onClick = { + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.timestamp_it)) }, + onClick = { + accountViewModel.timestamp(note) + onDismiss() + }, + ) + } + Divider() if (state.isPrivateBookmarkNote) { DropdownMenuItem( text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 06514959c..0d396edf5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -68,11 +68,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent -import com.vitorpamplona.quartz.events.TextNoteEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -232,7 +232,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { val replies = mutableMapOf() val takenIntoAccount = mutableSetOf() - LocalCache.notes.values.forEach { + LocalCache.noteListCache.forEach { val noteEvent = it.event if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { if (noteEvent is ReactionEvent) { @@ -256,10 +256,19 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) takenIntoAccount.add(noteEvent.id()) } - } else if (noteEvent is TextNoteEvent) { + } else if (noteEvent is BaseTextNoteEvent) { if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val isCitation = + noteEvent.findCitations().any { + LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser + } + val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 + if (isCitation) { + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + } else { + replies[netDate] = (replies[netDate] ?: 0) + 1 + } takenIntoAccount.add(noteEvent.id()) } } @@ -275,7 +284,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { refreshChartModel() } - suspend fun addToStatsSuspend(newNotes: Set) { + suspend fun addToStatsSuspend(newBlockNotes: Set>) { checkNotInMainThread() val currentUser = user.pubkeyHex @@ -287,39 +296,50 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { val takenIntoAccount = this.takenIntoAccount.toMutableSet() var hasNewElements = false - newNotes.forEach { - val noteEvent = it.event - if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { - if (noteEvent is ReactionEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - reactions[netDate] = (reactions[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { - val netDate = formatDate(noteEvent.createdAt()) - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is LnZapEvent) { - if ( - noteEvent.isTaggedUser(currentUser) - ) { // && noteEvent.pubKey != currentUser User might be sending his own receipts - val netDate = formatDate(noteEvent.createdAt) - zaps[netDate] = - (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is TextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true + newBlockNotes.forEach { newNotes -> + newNotes.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { + val netDate = formatDate(noteEvent.createdAt()) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is LnZapEvent) { + if ( + noteEvent.isTaggedUser(currentUser) + ) { // && noteEvent.pubKey != currentUser User might be sending his own receipts + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = + (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is BaseTextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val isCitation = + noteEvent.findCitations().any { + LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser + } + + val netDate = formatDate(noteEvent.createdAt) + if (isCitation) { + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + } else { + replies[netDate] = (replies[netDate] ?: 0) + 1 + } + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } } } } @@ -422,7 +442,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it.flatten().toSet()) } + bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it) } } override fun onCleared() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 85ce58f12..3c2e42730 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -213,7 +213,10 @@ fun DrawPlayName(name: String) { @Composable fun DrawPlayNameIcon(onClick: () -> Unit) { IconButton(onClick = onClick, modifier = StdButtonSizeModifier) { - PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText) + PlayIcon( + modifier = StdButtonSizeModifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 15b5cec1c..77581d245 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.LnZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException class ZapOptionstViewModel : ViewModel() { private var account: Account? = null @@ -104,11 +105,7 @@ class ZapOptionstViewModel : ViewModel() { } fun value(): Long? { - return try { - customAmount.text.trim().toLongOrNull() - } catch (e: Exception) { - null - } + return customAmount.text.trim().toLongOrNull() } fun cancel() {} @@ -283,7 +280,7 @@ fun ZapButton( onPost: () -> Unit, ) { Button( - onClick = { onPost() }, + onClick = { if (isActive) onPost() }, shape = ButtonBorder, colors = ButtonDefaults.buttonColors( @@ -446,6 +443,7 @@ fun payViaIntent( ContextCompat.startActivity(context, intent, null) } catch (e: Exception) { + if (e is CancellationException) throw e if (e.message != null) { onError(context.getString(R.string.no_wallet_found_with_error, e.message!!)) } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 21ef9ac84..91d114954 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt new file mode 100644 index 000000000..593f6bc3a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt @@ -0,0 +1,459 @@ +/** + * 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.ui.note + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ThemeType +import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.events.Event +import fr.acinq.secp256k1.Hex +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@Preview +@Composable +fun ZapTheDevsCardPreview() { + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + val myCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + sharedPreferencesViewModel.init() + sharedPreferencesViewModel.updateTheme(ThemeType.DARK) + + runBlocking(Dispatchers.IO) { + val releaseNotes = + """ + { + "id": "0465b20da0adf45dd612024d124e1ed384f7ecd2cd7358e77998828e7bf35fa2", + "pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "created_at": 1708014675, + "kind": 1, + "tags": [ + [ + "p", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b", + "", + "mention" + ], + [ + "p", + "7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf", + "", + "mention" + ], + [ + "t", + "Amethyst" + ], + [ + "t", + "amethyst" + ], + [ + "zap", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "wss://vitor.nostr1.com", + "0.6499999761581421" + ], + [ + "zap", + "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b", + "wss://nos.lol", + "0.25" + ], + [ + "zap", + "7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf", + "wss://vitor.nostr1.com", + "0.10000000149011612" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk" + ], + [ + "r", + "https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk" + ] + ], + "content": "#Amethyst v0.84.2: Text alignment fix\n\nBugfixes:\n- Fixes link misalignment in posts\n\nUpdated translations: \n- Czech, German, Swedish, and Portuguese by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef\n- French by nostr:npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk )\n- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk )", + "sig": "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed" + } + """.trimIndent() + + LocalCache.justConsume(Event.fromJson(releaseNotes), null) + } + + val accountViewModel = + AccountViewModel( + Account( + // blank keys + keyPair = + KeyPair( + privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), + pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"), + forcePubKeyCheck = false, + ), + scope = myCoroutineScope, + ), + sharedPreferencesViewModel.sharedPrefs, + ) + + LoadNote( + baseNoteHex = "0465b20da0adf45dd612024d124e1ed384f7ecd2cd7358e77998828e7bf35fa2", + accountViewModel, + ) { releaseNote -> + if (releaseNote != null) { + ThemeComparisonColumn( + onDark = { + ZapTheDevsCard( + releaseNote, + accountViewModel, + nav = {}, + ) + }, + onLight = { + ZapTheDevsCard( + releaseNote, + accountViewModel, + nav = {}, + ) + }, + ) + } + } +} + +@Composable +fun ZapTheDevsCard( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val releaseNoteState by baseNote.live().metadata.observeAsState() + val releaseNote = releaseNoteState?.note ?: return + + Row(modifier = Modifier.padding(horizontal = Size10dp)) { + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + // Title + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = R.string.zap_the_devs_title), + style = + TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ), + ) + + IconButton( + modifier = Size20Modifier, + onClick = { accountViewModel.markDonatedInThisVersion() }, + ) { + CloseIcon() + } + } + + Spacer(modifier = StdVertSpacer) + + ClickableText( + text = + buildAnnotatedString { + append(stringResource(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME)) + append(" ") + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append("#value4value") + } + }, + onClick = { nav("Hashtag/value4value") }, + ) + + Spacer(modifier = StdVertSpacer) + + val noteEvent = releaseNote.event + if (noteEvent != null) { + val route = + remember(releaseNote) { + routeFor(releaseNote, accountViewModel.userProfile()) + } + + if (route != null) { + ClickableText( + text = + buildAnnotatedString { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append(stringResource(id = R.string.version_name, BuildConfig.VERSION_NAME.substringBefore("-"))) + } + append(" " + stringResource(id = R.string.brought_to_you_by)) + }, + onClick = { nav(route) }, + ) + } else { + Text(stringResource(id = R.string.this_version_brought_to_you_by)) + } + + Spacer(modifier = StdVertSpacer) + + DisplayZapSplits( + noteEvent = noteEvent, + useAuthorIfEmpty = true, + accountViewModel = accountViewModel, + nav = nav, + ) + + Spacer(modifier = StdVertSpacer) + } + + ZapDonationButton( + baseNote = releaseNote, + grayTint = MaterialTheme.colorScheme.onPrimary, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun ZapDonationButton( + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + iconSize: Dp = Size20dp, + iconSizeModifier: Modifier = Size20Modifier, + animationSize: Dp = 14.dp, + nav: (String) -> Unit, +) { + var wantsToZap by remember { mutableStateOf(false) } + var showErrorMessageDialog by remember { mutableStateOf(null) } + var wantsToPay by + remember(baseNote) { + mutableStateOf>( + persistentListOf(), + ) + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var zappingProgress by remember { mutableFloatStateOf(0f) } + var hasZapped by remember { mutableStateOf(false) } + + Button( + onClick = { + zapClick( + baseNote, + accountViewModel, + context, + onZappingProgress = { progress: Float -> + scope.launch { zappingProgress = progress } + }, + onMultipleChoices = { wantsToZap = true }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onPayViaIntent = { wantsToPay = it }, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + if (wantsToZap) { + ZapAmountChoicePopup( + baseNote = baseNote, + iconSize = iconSize, + accountViewModel = accountViewModel, + onDismiss = { + wantsToZap = false + zappingProgress = 0f + }, + onChangeAmount = { + wantsToZap = false + }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { zappingProgress = it } + }, + onPayViaIntent = { wantsToPay = it }, + ) + } + + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + baseNote.author?.let { + scope.launch(Dispatchers.IO) { + val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) + nav(route) + } + } + }, + onDismiss = { showErrorMessageDialog = null }, + ) + } + + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog( + payingInvoices = wantsToPay, + accountViewModel = accountViewModel, + onClose = { wantsToPay = persistentListOf() }, + onError = { + wantsToPay = persistentListOf() + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = it + } + }, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = iconSizeModifier, + ) { + if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { + Spacer(ModifierWidth3dp) + + CircularProgressIndicator( + progress = + animateFloatAsState( + targetValue = zappingProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "ZapIconIndicator", + ) + .value, + modifier = remember { Modifier.size(animationSize) }, + strokeWidth = 2.dp, + color = grayTint, + ) + } else { + ObserveZapIcon( + baseNote, + accountViewModel, + ) { wasZappedByLoggedInUser -> + LaunchedEffect(wasZappedByLoggedInUser.value) { + hasZapped = wasZappedByLoggedInUser.value + if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) { + delay(1000) + accountViewModel.markDonatedInThisVersion() + } + } + + Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") { + if (it) { + ZappedIcon(iconSizeModifier) + } else { + ZapIcon(iconSizeModifier, grayTint) + } + } + } + } + } + + if (hasZapped) { + Text(text = stringResource(id = R.string.thank_you)) + } else { + Text(text = stringResource(id = R.string.donate_now)) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 47ee1ccd0..9f93e498e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt index a5f992666..3ede38d98 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index fec70d41c..f79e09099 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -29,28 +29,16 @@ import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.vitorpamplona.amethyst.R -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.amethyst.ui.uriToRoute +import kotlinx.coroutines.CancellationException @Composable fun NIP19QrCodeScanner(onScan: (String?) -> Unit) { SimpleQrCodeScanner { try { - val nip19 = Nip19.uriToRoute(it) - val startingPage = - when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> "Event/${nip19.hex}" - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" - else -> null - } - - if (startingPage != null) { - onScan(startingPage) - } else { - onScan(null) - } + onScan(uriToRoute(it)) } catch (e: Throwable) { + if (e is CancellationException) throw e Log.e("NIP19 Scanner", "Error parsing $it", e) // QR can be anything, do not throw errors. onScan(null) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index ae4e6ac1c..5c4f33f0f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 5555a2932..973d531ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.quartz.signers.NostrSignerExternal +import kotlinx.coroutines.CancellationException @Composable fun AccountScreen( @@ -143,6 +144,7 @@ fun LoggedInPage( activity.prepareToLaunchSigner() launcher.launch(it) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("Signer", "Error opening Signer app", e) accountViewModel.toast( R.string.error_opening_external_signer, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 3d4e3d591..c92cc8ed6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index fae3317c5..a68d895d7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -28,11 +28,12 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Hex -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey @@ -40,6 +41,7 @@ import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -83,9 +85,21 @@ class AccountStateViewModel() : ViewModel() { loginWithExternalSigner: Boolean = false, packageName: String = "", ) = withContext(Dispatchers.IO) { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.hexToByteArray() - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + val parsed = Nip19Bech32.uriToRoute(key)?.entity + val pubKeyParsed = + when (parsed) { + is Nip19Bech32.NSec -> null + is Nip19Bech32.NPub -> parsed.hex.hexToByteArray() + is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray() + is Nip19Bech32.Note -> null + is Nip19Bech32.NEvent -> null + is Nip19Bech32.NEmbed -> null + is Nip19Bech32.NRelay -> null + is Nip19Bech32.NAddress -> null + else -> null + } + + val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort) if (loginWithExternalSigner && pubKeyParsed == null) { throw Exception("Invalid key while trying to login with external signer") @@ -194,29 +208,77 @@ class AccountStateViewModel() : ViewModel() { fun login( key: String, + password: String, useProxy: Boolean, proxyPort: Int, loginWithExternalSigner: Boolean = false, packageName: String = "", - onError: () -> Unit, + onError: (String?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - try { - loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) - } catch (e: Exception) { - Log.e("Login", "Could not sign in", e) - onError() + if (key.startsWith("ncryptsec")) { + val newKey = + try { + CryptoUtils.decryptNIP49(key, password) + } catch (e: Exception) { + if (e is CancellationException) throw e + onError(e.message) + return@launch + } + + if (newKey == null) { + onError("Could not decrypt key with provided password") + Log.e("Login", "Could not decrypt ncryptsec") + } else { + loginSync(newKey, useProxy, proxyPort, loginWithExternalSigner, packageName) { + onError(null) + } + } + } else { + loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName) { + onError(null) + } } } } + fun login( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + onError: () -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName, onError) + } + } + + suspend fun loginSync( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + onError: () -> Unit, + ) { + try { + loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Login", "Could not sign in", e) + onError() + } + } + fun newKey( useProxy: Boolean, proxyPort: Int, name: String? = null, ) { viewModelScope.launch(Dispatchers.IO) { - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort) val keyPair = KeyPair() val account = Account( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index f23a78a73..a492d570a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index 6232e04e4..2d24b634c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -45,10 +46,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.ZapTheDevsCard import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -176,6 +180,10 @@ private fun FeedLoaded( contentPadding = FeedPadding, state = listState, ) { + item { + ShowDonationCard(accountViewModel, nav) + } + itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } @@ -192,6 +200,28 @@ private fun FeedLoaded( } } +@Composable +private fun ShowDonationCard( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val account by accountViewModel.account.live.observeAsState() + if (account?.account?.hasDonatedInThisVersion() == false) { + LoadNote( + BuildConfig.RELEASE_NOTES_ID, + accountViewModel, + ) { loadedNoteId -> + if (loadedNoteId != null) { + ZapTheDevsCard( + loadedNoteId, + accountViewModel, + nav, + ) + } + } + } +} + @Composable private fun RenderCardItem( item: Card, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 3bc23190d..f35f36ff6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -179,7 +179,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { val event = (zapEvent.event as LnZapEvent) val author = event.zappedAuthor().firstNotNullOfOrNull { - LocalCache.users[it] // don't create user if it doesn't exist + LocalCache.getUserIfExists(it) // don't create user if it doesn't exist } if (author != null) { val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index bca0cb9af..bf5720b83 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 5c1309b32..ffa1a6f7e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt index bae5ad4e2..ea9d5ac64 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index c1e25758e..f1064990c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index c7d6340b6..d2610f923 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt index 1002208f8..387dc1f9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index a245e3829..5c0c70736 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 1ad83429d..1465d73ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt index d3d8c1c6b..5203b1b8b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index 969426642..b2d928fcc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 0037aa303..29ce5e5d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt index 40914cba6..14aec737f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt index 526272cda..f4c355fdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt index 6425aad40..7db649be8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 80dd11153..2d3153de5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,21 +76,26 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.InlineCarrousel +import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost import com.vitorpamplona.amethyst.ui.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.elements.DisplayReward import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits import com.vitorpamplona.amethyst.ui.elements.Reward +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.AudioHeader import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader @@ -98,11 +104,14 @@ import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.CreateImageHeader import com.vitorpamplona.amethyst.ui.note.DisplayHighlight import com.vitorpamplona.amethyst.ui.note.DisplayLocation +import com.vitorpamplona.amethyst.ui.note.DisplayOts import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu @@ -111,6 +120,8 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent +import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.RenderPoll import com.vitorpamplona.amethyst.ui.note.RenderPostApproval @@ -127,7 +138,10 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote @@ -141,9 +155,12 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent +import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GitPatchEvent +import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -152,11 +169,13 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.VideoEvent +import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @Composable @@ -284,26 +303,27 @@ fun Modifier.drawReplyLevel( color: Color, selected: Color, ): Modifier = - this.drawBehind { - val paddingDp = 2 - val strokeWidthDp = 2 - val levelWidthDp = strokeWidthDp + 1 - - val padding = paddingDp.dp.toPx() - val strokeWidth = strokeWidthDp.dp.toPx() - val levelWidth = levelWidthDp.dp.toPx() - - repeat(level) { - this.drawLine( - if (it == level - 1) selected else color, - Offset(padding + it * levelWidth, 0f), - Offset(padding + it * levelWidth, size.height), - strokeWidth = strokeWidth, - ) - } + this + .drawBehind { + val paddingDp = 2 + val strokeWidthDp = 2 + val levelWidthDp = strokeWidthDp + 1 + + val padding = paddingDp.dp.toPx() + val strokeWidth = strokeWidthDp.dp.toPx() + val levelWidth = levelWidthDp.dp.toPx() + + repeat(level) { + this.drawLine( + if (it == level - 1) selected else color, + Offset(padding + it * levelWidth, 0f), + Offset(padding + it * levelWidth, size.height), + strokeWidth = strokeWidth, + ) + } - return@drawBehind - } + return@drawBehind + } .padding(start = (2 + (level * 3)).dp) @OptIn(ExperimentalFoundationApi::class) @@ -353,11 +373,14 @@ fun NoteMaster( ) } else { Column( - modifier.fillMaxWidth().padding(top = 10.dp), + modifier + .fillMaxWidth() + .padding(top = 10.dp), ) { Row( modifier = - Modifier.padding(start = 12.dp, end = 12.dp) + Modifier + .padding(start = 12.dp, end = 12.dp) .clickable(onClick = { note.author?.let { nav("User/${it.pubkeyHex}") } }), ) { NoteAuthorPicture( @@ -391,13 +414,13 @@ fun NoteMaster( ) IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), + modifier = Size24Modifier, onClick = enablePopup, ) { Icon( imageVector = Icons.Default.MoreVert, null, - modifier = Modifier.size(15.dp), + modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) @@ -427,6 +450,8 @@ fun NoteMaster( if (pow > 20) { DisplayPoW(pow) } + + DisplayOts(note, accountViewModel) } } } @@ -437,13 +462,16 @@ fun NoteMaster( BadgeDisplay(baseNote = note) } else if (noteEvent is LongTextNoteEvent) { RenderLongFormHeaderForThread(noteEvent) + } else if (noteEvent is WikiNoteEvent) { + RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) } else if (noteEvent is ClassifiedsEvent) { RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav) } Row( modifier = - Modifier.padding(horizontal = 12.dp) + Modifier + .padding(horizontal = 12.dp) .combinedClickable( onClick = {}, onLongClick = { popupExpanded = true }, @@ -504,6 +532,10 @@ fun NoteMaster( accountViewModel, nav, ) + } else if (noteEvent is GitRepositoryEvent) { + RenderGitRepositoryEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is GitPatchEvent) { + RenderGitPatchEvent(baseNote, false, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { @@ -556,7 +588,7 @@ fun NoteMaster( val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } if (zapSplits && noteEvent != null) { Spacer(modifier = DoubleVertSpacer) - DisplayZapSplits(noteEvent, accountViewModel, nav) + DisplayZapSplits(noteEvent, false, accountViewModel, nav) } ReactionsRow(note, true, accountViewModel, nav) @@ -669,7 +701,9 @@ private fun RenderClassifiedsReaderForThread( } Row( - Modifier.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp).fillMaxWidth(), + Modifier + .padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp) + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -686,7 +720,9 @@ private fun RenderClassifiedsReaderForThread( Row( modifier = - Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp).fillMaxWidth(), + Modifier + .padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -703,6 +739,7 @@ private fun RenderClassifiedsReaderForThread( } var message by remember { mutableStateOf(TextFieldValue(msg)) } + val scope = rememberCoroutineScope() TextField( value = message, @@ -725,7 +762,11 @@ private fun RenderClassifiedsReaderForThread( isActive = message.text.isNotBlank(), modifier = EditFieldTrailingIconModifier, ) { - note.author?.let { nav(routeToMessage(it, msg, accountViewModel)) } + scope.launch(Dispatchers.IO) { + note.author?.let { + nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel)) + } + } } }, colors = @@ -780,3 +821,137 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { } } } + +@Preview +@Composable +private fun RenderWikiHeaderForThreadPreview() { + val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n![](https://play-lh.googleusercontent.com/lvZlAm9dBrpHeOo7sIPKCsiKOLYLhR2b0FiOT4tyiwWO2dvsR2gDS0xk9tOOr9U-6uM=w240-h480-rw)\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiNoteEvent + val accountViewModel = mockAccountViewModel() + val nav: (String) -> Unit = {} + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume(event, null) + } + } + + LoadNote(baseNoteHex = "277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1", accountViewModel = accountViewModel) { baseNote -> + ThemeComparisonColumn( + onDark = { + val bg = MaterialTheme.colorScheme.background + val backgroundColor = + remember { + mutableStateOf(bg) + } + + Column { + RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav) + RenderTextEvent( + baseNote!!, + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } + }, + onLight = { + val bg = MaterialTheme.colorScheme.background + val backgroundColor = + remember { + mutableStateOf(bg) + } + + Column { + RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav) + RenderTextEvent( + baseNote!!, + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } + }, + ) + } +} + +@Composable +private fun RenderWikiHeaderForThread( + noteEvent: WikiNoteEvent, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } + + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + noteEvent.image()?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + + noteEvent.title()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + ) + } + + forkedAddress?.let { + LoadAddressableNote(aTag = it, accountViewModel = accountViewModel) { originalVersion -> + if (originalVersion != null) { + ForkInformationRow(originalVersion, Modifier.fillMaxWidth(), accountViewModel, nav) + } + } + } + + noteEvent + .summary() + ?.ifBlank { null } + ?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + modifier = Modifier.fillMaxWidth(), + color = Color.Gray, + ) + } + } + } +} + +@Composable +fun ForkInformationRow( + originalVersion: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + Text(stringResource(id = R.string.forked_from)) + Spacer(modifier = StdHorzSpacer) + LoadAndDisplayUser(author, route, nav) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index 6b687e67e..2feffee92 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index b82da0e36..6fc453d92 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 352674e31..4804b3cb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 28871c35d..5faaeb570 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -36,23 +36,48 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -67,10 +92,14 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.note.authenticate import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toNsec import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AccountBackupDialog( accountViewModel: AccountViewModel, @@ -80,12 +109,23 @@ fun AccountBackupDialog( onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Surface(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { Column( - modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), ) { Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -93,7 +133,10 @@ fun AccountBackupDialog( } Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 30.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -101,13 +144,105 @@ fun AccountBackupDialog( style = RichTextStyle().resolveDefaults(), ) { Markdown( - content = stringResource(R.string.account_backup_tips_md), + content = stringResource(R.string.account_backup_tips2_md), ) } - Spacer(modifier = Modifier.height(30.dp)) + Spacer(modifier = Modifier.height(10.dp)) NSecCopyButton(accountViewModel) + + Spacer(modifier = Modifier.height(30.dp)) + + Material3RichText( + style = RichTextStyle().resolveDefaults(), + ) { + Markdown( + content = stringResource(R.string.account_backup_tips3_md), + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + val password = remember { mutableStateOf(TextFieldValue("")) } + var errorMessage by remember { mutableStateOf("") } + var showCharsPassword by remember { mutableStateOf(false) } + + val autofillNode = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { password.value = TextFieldValue(it) }, + ) + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNode + + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNode.boundingBox = coordinates.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + }, + value = password.value, + onValueChange = { + password.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) + + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + EncryptNSecCopyButton(accountViewModel, password) } } } @@ -160,6 +295,56 @@ private fun NSecCopyButton(accountViewModel: AccountViewModel) { } } +@Composable +private fun EncryptNSecCopyButton( + accountViewModel: AccountViewModel, + password: MutableState, +) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) + } + } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + authenticate( + title = context.getString(R.string.copy_my_secret_key), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + enabled = password.value.text.isNotBlank(), + ) { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + imageVector = Icons.Default.Key, + contentDescription = + stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), + modifier = Modifier.padding(end = 5.dp), + ) + Text( + stringResource(id = R.string.encrypt_and_copy_my_secret_key), + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + fun Context.getFragmentActivity(): FragmentActivity? { var currentContext = this while (currentContext is ContextWrapper) { @@ -189,3 +374,46 @@ private fun copyNSec( } } } + +private fun encryptCopyNSec( + password: MutableState, + context: Context, + scope: CoroutineScope, + accountViewModel: AccountViewModel, + clipboardManager: ClipboardManager, +) { + if (password.value.text.isBlank()) { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.password_is_required), + Toast.LENGTH_SHORT, + ) + .show() + } + } else { + accountViewModel.account.keyPair.privKey?.let { + val key = CryptoUtils.encryptNIP49(it.toHexKey(), password.value.text) + if (key != null) { + clipboardManager.setText(AnnotatedString(key)) + scope.launch { + Toast.makeText( + context, + context.getString(R.string.secret_key_copied_to_clipboard), + Toast.LENGTH_SHORT, + ) + .show() + } + } else { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.failed_to_encrypt_key), + Toast.LENGTH_SHORT, + ) + .show() + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index dc7dd7ed1..6b6969f78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -43,13 +43,12 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.CashuToken -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever @@ -69,10 +68,12 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.SettingsState import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip11RelayInformation +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.LnZapEvent @@ -87,6 +88,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -94,6 +96,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.util.Locale import kotlin.coroutines.resume @@ -103,7 +106,11 @@ import kotlin.time.measureTimedValue @Immutable class StringToastMsg(val title: String, val msg: String) : ToastMsg() -@Immutable class ResourceToastMsg(val titleResId: Int, val resourceId: Int) : ToastMsg() +@Immutable class ResourceToastMsg( + val titleResId: Int, + val resourceId: Int, + val params: Array? = null, +) : ToastMsg() @Stable class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao { @@ -139,6 +146,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) } } + fun toast( + titleResId: Int, + resourceId: Int, + vararg params: String, + ) { + viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId, params)) } + } + fun isWriteable(): Boolean { return account.isWriteable() } @@ -514,7 +529,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } fun broadcast(note: Note) { - account.broadcast(note) + viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) } + } + + fun timestamp(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) } + } + + var lastTimeItTriedToUpdateAttestations: Long = 0 + + fun upgradeAttestations() { + // only tries to upgrade every hour + val now = TimeUtils.now() + if (now - lastTimeItTriedToUpdateAttestations > TimeUtils.ONE_HOUR) { + lastTimeItTriedToUpdateAttestations = now + viewModelScope.launch(Dispatchers.IO) { account.updateAttestations() } + } } fun delete(note: Note) { @@ -633,6 +663,12 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View account.updateShowSensitiveContent(null) } + fun markDonatedInThisVersion() { + viewModelScope.launch { + account.markDonatedInThisVersion() + } + } + fun defaultZapType(): LnZapEvent.ZapType { return account.defaultZapType } @@ -786,7 +822,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View fun retrieveRelayDocument( dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, + onInfo: (Nip11RelayInformation) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { @@ -832,6 +868,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) } } + fun checkGetOrCreateNote( + event: Event, + onResult: (Note?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + var note = checkGetOrCreateNote(event.id) + + if (note == null) { + LocalCache.verifyAndConsume(event, null) + note = checkGetOrCreateNote(event.id) + } + + onResult(note) + } + } + fun getNoteIfExists(hex: HexKey): Note? { return LocalCache.getNoteIfExists(hex) } @@ -862,11 +914,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View return LocalCache.addressables[key] } - fun findStatusesForUser( + suspend fun findStatusesForUser( myUser: User, onResult: (ImmutableList) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) } + withContext(Dispatchers.IO) { + onResult(LocalCache.findStatusesForUser(myUser)) + } + } + + suspend fun findOtsEventsForNote( + note: Note, + onResult: (Long?) -> Unit, + ) { + withContext(Dispatchers.IO) { + onResult(LocalCache.findEarliestOtsForNote(note)) + } } private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { @@ -923,7 +986,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View fun returnNIP19References( content: String, tags: ImmutableListOfLists?, - onNewReferences: (List) -> Unit, + onNewReferences: (List) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { onNewReferences(MarkdownParser().returnNIP19References(content, tags)) @@ -940,17 +1003,32 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } - fun parseNIP19( + suspend fun parseNIP19( str: String, onNote: (LoadedBechLink) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { - Nip19.uriToRoute(str)?.let { + withContext(Dispatchers.IO) { + Nip19Bech32.uriToRoute(str)?.let { var returningNote: Note? = null - if ( - it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS - ) { - LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> returningNote = note } + + when (val parsed = it.entity) { + is Nip19Bech32.NSec -> {} + is Nip19Bech32.NPub -> {} + is Nip19Bech32.NProfile -> {} + is Nip19Bech32.Note -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } + is Nip19Bech32.NEvent -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } + is Nip19Bech32.NEmbed -> { + if (LocalCache.getNoteIfExists(parsed.event.id) == null) { + LocalCache.verifyAndConsume(parsed.event, null) + } + + LocalCache.checkGetOrCreateNote(parsed.event.id)?.let { note -> + returningNote = note + } + } + is Nip19Bech32.NRelay -> {} + is Nip19Bech32.NAddress -> LocalCache.checkGetOrCreateNote(parsed.atag)?.let { note -> returningNote = note } + else -> {} } onNote(LoadedBechLink(returningNote, it)) @@ -1042,7 +1120,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View ) { viewModelScope.launch(Dispatchers.IO) { account.proxyPort = portNumber.value.toInt() - account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) + account.proxy = HttpClientManager.initProxy(checked, "127.0.0.1", account.proxyPort) account.saveable.invalidateData() serviceManager?.forceRestart() } @@ -1080,6 +1158,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", ) invalidateInsertData(newNotes) + upgradeAttestations() } } } @@ -1102,6 +1181,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View val myCover = context.imageLoader.execute(request).drawable onReady(myCover) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("VideoView", "Fail to load cover $thumbUri", e) onError(e.message) } @@ -1173,6 +1253,55 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View ) } } + + fun unwrapIfNeeded( + event: EventInterface?, + onReady: (Note) -> Unit, + ) { + when (event) { + is GiftWrapEvent -> { + event.cachedGift(account.signer) { + val existingNote = LocalCache.getNoteIfExists(it.id) + if (existingNote != null) { + unwrapIfNeeded(existingNote.event, onReady) + } else { + LocalCache.verifyAndConsume(it, null) + unwrapIfNeeded(it, onReady) + } + } + } + is SealedGossipEvent -> { + event.cachedGossip(account.signer) { + val existingNote = LocalCache.getNoteIfExists(it.id) + if (existingNote != null) { + unwrapIfNeeded(existingNote.event, onReady) + } else { + // this is not verifiable + LocalCache.justConsume(it, null) + unwrapIfNeeded(it, onReady) + } + } + } + else -> { + event?.id()?.let { + LocalCache.getNoteIfExists(it)?.let { + onReady(it) + } + } + } + } + } + + fun unwrapIfNeeded( + note: Note?, + onReady: (Note) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + unwrapIfNeeded(note?.event) { + onReady(it) + } + } + } } class HasNotificationDot(bottomNavigationItems: ImmutableList) { @@ -1200,7 +1329,7 @@ class HasNotificationDot(bottomNavigationItems: ImmutableList) { } } -@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return) +@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19Bech32.ParseReturn) public fun allOrNothingSigningOperations( remainingTos: List, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index 9e1244322..60f286ea7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index a3b3d692d..0a586fd87 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -38,10 +38,12 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.EditNote @@ -99,6 +101,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.MediaUrlVideo import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel @@ -112,12 +115,12 @@ import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.ServerOption import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose @@ -156,6 +159,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.Participant +import com.vitorpamplona.quartz.events.findURLs import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -268,7 +272,13 @@ fun ChannelScreen( val replyTo = remember { mutableStateOf(null) } Column( - modifier = remember { Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) }, + modifier = + remember { + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + }, ) { if (channel is LiveActivitiesChannel) { ShowVideoStreaming(channel, accountViewModel) @@ -300,6 +310,10 @@ fun ChannelScreen( dao = accountViewModel, ) tagger.run() + + val urls = findURLs(tagger.message) + val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + if (channel is PublicChatChannel) { accountViewModel.account.sendChannelMessage( message = tagger.message, @@ -307,6 +321,7 @@ fun ChannelScreen( replyTo = tagger.eTags, mentions = tagger.pTags, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } else if (channel is LiveActivitiesChannel) { accountViewModel.account.sendLiveMessage( @@ -315,6 +330,7 @@ fun ChannelScreen( replyTo = tagger.eTags, mentions = tagger.pTags, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } newPostModel.message = TextFieldValue("") @@ -333,7 +349,11 @@ fun DisplayReplyingToNote( onCancel: () -> Unit, ) { Row( - Modifier.padding(horizontal = 10.dp).animateContentSize(), + Modifier + .padding(horizontal = 10.dp) + .heightIn(max = 100.dp) + .verticalScroll(rememberScrollState()) + .animateContentSize(), verticalAlignment = Alignment.CenterVertically, ) { if (replyingNote != null) { @@ -356,7 +376,10 @@ fun DisplayReplyingToNote( Icon( imageVector = Icons.Default.Cancel, null, - modifier = Modifier.padding(end = 5.dp).size(30.dp), + modifier = + Modifier + .padding(end = 5.dp) + .size(30.dp), tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -372,13 +395,13 @@ fun EditFieldRow( accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit, ) { - Row( + Column( modifier = EditFieldModifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { val context = LocalContext.current + ShowUserSuggestionList(channelScreenModel, accountViewModel) + MyTextField( value = channelScreenModel.message, onValueChange = { channelScreenModel.updateMessage(it) }, @@ -387,7 +410,7 @@ fun EditFieldRow( capitalization = KeyboardCapitalization.Sentences, ), shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), + modifier = Modifier.fillMaxWidth(), placeholder = { Text( text = stringResource(R.string.reply_here), @@ -424,6 +447,7 @@ fun EditFieldRow( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), ) } } @@ -657,7 +681,7 @@ fun ShowVideoStreaming( ) { val zoomableUrlVideo = remember(it) { - ZoomableUrlVideo( + MediaUrlVideo( url = url, description = title, artworkUri = artworkUri, @@ -718,7 +742,11 @@ fun ShortChannelHeader( } Column( - modifier = Modifier.padding(start = 10.dp).height(35.dp).weight(1f), + modifier = + Modifier + .padding(start = 10.dp) + .height(35.dp) + .weight(1f), verticalArrangement = Arrangement.Center, ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -731,7 +759,10 @@ fun ShortChannelHeader( } Row( - modifier = Modifier.height(Size35dp).padding(start = 5.dp), + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { if (channel is PublicChatChannel) { @@ -1021,7 +1052,12 @@ fun LiveFlag() { fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = - remember { Modifier.clip(SmallBorder).background(Color.Red).padding(horizontal = 5.dp) }, + remember { + Modifier + .clip(SmallBorder) + .background(Color.Red) + .padding(horizontal = 5.dp) + }, ) } @@ -1032,7 +1068,12 @@ fun EndedFlag() { color = Color.White, fontWeight = FontWeight.Bold, modifier = - remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, ) } @@ -1043,7 +1084,12 @@ fun OfflineFlag() { color = Color.White, fontWeight = FontWeight.Bold, modifier = - remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, ) } @@ -1057,7 +1103,12 @@ fun ScheduledFlag(starts: Long?) { color = Color.White, fontWeight = FontWeight.Bold, modifier = - remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, ) } @@ -1066,7 +1117,10 @@ private fun NoteCopyButton(note: Channel) { var popupExpanded by remember { mutableStateOf(false) } Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = { popupExpanded = true }, shape = ButtonBorder, colors = @@ -1109,7 +1163,10 @@ private fun EditButton( } Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = { wantsToPost = true }, contentPadding = ZeroPadding, ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 4f8965bfc..94d4fa306 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -225,6 +225,7 @@ fun ChatroomListScreenOnlyList( val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { + NostrChatroomListDataSource.account = accountViewModel.account NostrChatroomListDataSource.start() } } @@ -303,6 +304,7 @@ fun WatchAccountForListScreen( ) { LaunchedEffect(accountViewModel) { launch(Dispatchers.IO) { + NostrChatroomListDataSource.account = accountViewModel.account NostrChatroomListDataSource.start() knownFeedViewModel.invalidateData(true) newFeedViewModel.invalidateData(true) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index d045b83f1..41df0fd51 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -25,11 +25,13 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -92,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.actions.ServerOption import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayRoomSubject @@ -115,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey +import com.vitorpamplona.quartz.events.findURLs import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers @@ -324,6 +328,9 @@ fun ChatroomScreen( // LAST ROW PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { scope.launch(Dispatchers.IO) { + val urls = findURLs(newPostModel.message.text) + val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { accountViewModel.account.sendNIP24PrivateMessage( message = newPostModel.message.text, @@ -331,6 +338,7 @@ fun ChatroomScreen( replyingTo = replyTo.value, mentions = null, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } else { accountViewModel.account.sendPrivateMessage( @@ -339,6 +347,7 @@ fun ChatroomScreen( replyingTo = replyTo.value, mentions = null, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } @@ -357,13 +366,13 @@ fun PrivateMessageEditFieldRow( accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit, ) { - Row( + Column( modifier = EditFieldModifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { val context = LocalContext.current + ShowUserSuggestionList(channelScreenModel, accountViewModel) + MyTextField( value = channelScreenModel.message, onValueChange = { channelScreenModel.updateMessage(it) }, @@ -372,7 +381,7 @@ fun PrivateMessageEditFieldRow( capitalization = KeyboardCapitalization.Sentences, ), shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), + modifier = Modifier.fillMaxWidth(), placeholder = { Text( text = stringResource(R.string.reply_here), @@ -455,10 +464,36 @@ fun PrivateMessageEditFieldRow( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), ) } } +@Composable +fun ShowUserSuggestionList( + channelScreenModel: NewPostViewModel, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), +) { + val userSuggestions = channelScreenModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = + PaddingValues( + top = 10.dp, + ), + modifier = modifier, + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex }, + ) { _, item -> + UserLine(item, accountViewModel) { channelScreenModel.autocompleteWithUser(item) } + } + } + } +} + @Composable fun NewFeatureNIP24AlertDialog( accountViewModel: AccountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt index dbc623aa2..9a5cb0b12 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index 64b2536c4..5815c9fc7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.CancellationException @Composable fun ConnectOrbotDialog( @@ -83,7 +84,8 @@ fun ConnectOrbotDialog( onPost = { try { Integer.parseInt(portNumber.value) - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e onError(toastMessage) return@UseOrbotButton } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index c9d74cf9a..e42c2fe6c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt index 3ba380791..33b0dc61c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index 5caaa7b69..de08249ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 06364986e..1381ee22b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 87155161d..7773764f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index d7d3c52f9..51143cfc3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index c2916d3b3..44c613ee2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -270,6 +270,7 @@ fun MainScreen( videoFeedViewModel.sendToTop() } Route.Discover.base -> { + discoverMarketplaceFeedViewModel.sendToTop() discoveryLiveFeedViewModel.sendToTop() discoveryCommunityFeedViewModel.sendToTop() discoveryChatFeedViewModel.sendToTop() @@ -472,12 +473,22 @@ private fun DisplayErrorMessages(accountViewModel: AccountViewModel) { openDialogMsg.value?.let { obj -> when (obj) { is ResourceToastMsg -> - InformationDialog( - context.getString(obj.titleResId), - context.getString(obj.resourceId), - ) { - accountViewModel.clearToasts() + if (obj.params != null) { + InformationDialog( + context.getString(obj.titleResId), + context.getString(obj.resourceId, *obj.params), + ) { + accountViewModel.clearToasts() + } + } else { + InformationDialog( + context.getString(obj.titleResId), + context.getString(obj.resourceId), + ) { + accountViewModel.clearToasts() + } } + is StringToastMsg -> InformationDialog( obj.title, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 6a741006e..99269225e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index b1996662d..094682cbe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -112,6 +112,7 @@ import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -127,7 +128,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -155,7 +155,9 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size25Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -390,7 +392,10 @@ private fun RenderSurface( var tabsSize by remember { mutableStateOf(IntSize.Zero) } Column( - modifier = Modifier.fillMaxSize().onSizeChanged { columnSize = it }, + modifier = + Modifier + .fillMaxSize() + .onSizeChanged { columnSize = it }, ) { val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -403,7 +408,8 @@ private fun RenderSurface( Box( modifier = remember { - Modifier.verticalScroll(scrollState) + Modifier + .verticalScroll(scrollState) .nestedScroll( object : NestedScrollConnection { override fun onPreScroll( @@ -726,10 +732,17 @@ private fun ProfileHeader( DrawBanner(baseUser, accountViewModel) Box( - modifier = Modifier.padding(horizontal = 10.dp).size(40.dp).align(Alignment.TopEnd), + modifier = + Modifier + .padding(horizontal = 10.dp) + .size(40.dp) + .align(Alignment.TopEnd), ) { Button( - modifier = Modifier.size(30.dp).align(Alignment.Center), + modifier = + Modifier + .size(30.dp) + .align(Alignment.Center), onClick = { popupExpanded = true }, shape = ButtonBorder, colors = @@ -754,7 +767,11 @@ private fun ProfileHeader( } Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp) + .padding(top = 75.dp), ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -789,7 +806,10 @@ private fun ProfileHeader( Spacer(Modifier.weight(1f)) Row( - modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), + modifier = + Modifier + .height(Size35dp) + .padding(bottom = 3.dp), ) { MessageButton(baseUser, accountViewModel, nav) @@ -806,7 +826,7 @@ private fun ProfileHeader( val profilePic = baseUser.profilePicture() if (zoomImageDialogOpen && profilePic != null) { ZoomableImageDialog( - figureOutMimeType(profilePic), + RichTextParser.parseImageOrVideo(profilePic), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -969,13 +989,16 @@ private fun DrawAdditionalInfo( ) IconButton( - modifier = Modifier.size(25.dp).padding(start = 5.dp), + modifier = + Modifier + .size(25.dp) + .padding(start = 5.dp), onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) }, ) { Icon( imageVector = Icons.Default.ContentCopy, - null, - modifier = Modifier.size(15.dp), + contentDescription = stringResource(id = R.string.copy_npub_to_clipboard), + modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -995,13 +1018,13 @@ private fun DrawAdditionalInfo( } IconButton( - modifier = Modifier.size(25.dp), + modifier = Size25Modifier, onClick = { dialogOpen = true }, ) { Icon( painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(15.dp), + contentDescription = stringResource(id = R.string.show_npub_as_a_qr_code), + modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -1059,7 +1082,10 @@ private fun DrawAdditionalInfo( text = AnnotatedString(identity.identity), onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), + modifier = + Modifier + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + .weight(1f), ) } } @@ -1131,7 +1157,10 @@ fun DisplayLNAddress( text = AnnotatedString(lud16), onClick = { zapExpanded = !zapExpanded }, style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), + modifier = + Modifier + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + .weight(1f), ) } @@ -1223,12 +1252,21 @@ private fun WatchApp( appLogo?.let { Box( - remember { Modifier.size(Size35dp).clickable { nav("Note/${baseApp.idHex}") } }, + remember { + Modifier + .size(Size35dp) + .clickable { nav("Note/${baseApp.idHex}") } + }, ) { AsyncImage( model = appLogo, contentDescription = null, - modifier = remember { Modifier.size(Size35dp).clip(shape = CircleShape) }, + modifier = + remember { + Modifier + .size(Size35dp) + .clip(shape = CircleShape) + }, ) } } @@ -1343,7 +1381,11 @@ fun BadgeThumb( onClick: ((String) -> Unit)? = null, ) { Box( - remember { Modifier.width(size).height(size) }, + remember { + Modifier + .width(size) + .height(size) + }, ) { WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick) } @@ -1373,7 +1415,13 @@ private fun WatchAndRenderBadgeImage( RobohashAsyncImage( robot = "authornotfound", contentDescription = stringResource(R.string.unknown_author), - modifier = remember { pictureModifier.width(size).height(size).background(bgColor) }, + modifier = + remember { + pictureModifier + .width(size) + .height(size) + .background(bgColor) + }, ) } else { RobohashFallbackAsyncImage( @@ -1418,7 +1466,8 @@ fun DrawBanner( contentDescription = stringResource(id = R.string.profile_image), contentScale = ContentScale.FillWidth, modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .height(125.dp) .combinedClickable( onClick = { zoomImageDialogOpen = true }, @@ -1428,7 +1477,7 @@ fun DrawBanner( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = figureOutMimeType(banner), + imageUrl = RichTextParser.parseImageOrVideo(banner), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -1438,7 +1487,10 @@ fun DrawBanner( painter = painterResource(R.drawable.profile_banner), contentDescription = stringResource(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth().height(125.dp), + modifier = + Modifier + .fillMaxWidth() + .height(125.dp), ) } } @@ -1692,7 +1744,10 @@ private fun MessageButton( val scope = rememberCoroutineScope() Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = { scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav("Room/$it") } } }, @@ -1727,7 +1782,10 @@ private fun InnerEditButtonPreview() { @Composable private fun InnerEditButton(onClick: () -> Unit) { Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = onClick, contentPadding = ZeroPadding, ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index 41753ac08..69c921b61 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 8a0df8ac4..57cc6bbca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt index e103be0f4..fbc8e0db7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt index fc0eac715..78219e9b0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index b1951014d..24ff01a66 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -476,9 +476,12 @@ fun ReactionsColumn( accountViewModel = accountViewModel, iconSizeModifier = Size40Modifier, iconSize = Size40dp, - ) { - wantsToQuote = baseNote - } + onQuotePress = { + wantsToQuote = baseNote + }, + onForkPress = { + }, + ) LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onBackground, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt index 358d42c1d..9957886c6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -39,11 +39,11 @@ fun LoginOrSignupScreen( Crossfade(wantsNewUser, label = "LoginOrSignupScreen") { if (it) { - SignUpPage(accountViewModel = accountViewModel) { + SignUpPage(accountStateViewModel = accountViewModel) { wantsNewUser = false } } else { - LoginPage(accountViewModel = accountViewModel, isFirstLogin = isFirstLogin) { + LoginPage(accountStateViewModel = accountViewModel, isFirstLogin = isFirstLogin) { wantsNewUser = true } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index e4ce9040e..f4cd1395e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -49,11 +49,13 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -89,10 +91,12 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.PackageUtils import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -100,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.SignerType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.UUID @@ -122,7 +127,7 @@ fun LoginPage() { @OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginPage( - accountViewModel: AccountStateViewModel, + accountStateViewModel: AccountStateViewModel, isFirstLogin: Boolean, onWantsToLogin: () -> Unit, ) { @@ -140,6 +145,16 @@ fun LoginPage( val scope = rememberCoroutineScope() var loginWithExternalSigner by remember { mutableStateOf(false) } + var processingLogin by remember { mutableStateOf(false) } + + val password = remember { mutableStateOf(TextFieldValue("")) } + val needsPassword = + remember { + derivedStateOf { + key.value.text.startsWith("ncryptsec1") + } + } + if (loginWithExternalSigner) { val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } val id = remember { UUID.randomUUID().toString() } @@ -172,6 +187,7 @@ fun LoginPage( activity.prepareToLaunchSigner() launcher.launch(it) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("Signer", "Error opening Signer app", e) scope.launch(Dispatchers.Main) { Toast.makeText( @@ -208,7 +224,7 @@ fun LoginPage( } if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login( + accountStateViewModel.login( key.value.text, useProxy.value, proxyPort.value.toInt(), @@ -240,33 +256,47 @@ fun LoginPage( Spacer(modifier = Modifier.height(40.dp)) - var showPassword by remember { mutableStateOf(false) } + var showCharsKey by remember { mutableStateOf(false) } + var showCharsPassword by remember { mutableStateOf(false) } + + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, + ) - val autofillNode = + val autofillNodePassword = AutofillNode( autofillTypes = listOf(AutofillType.Password), onFill = { key.value = TextFieldValue(it) }, ) + val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword OutlinedTextField( modifier = Modifier .onGloballyPositioned { coordinates -> - autofillNode.boundingBox = coordinates.boundsInWindow() + autofillNodeKey.boundingBox = coordinates.boundsInWindow() } .onFocusChanged { focusState -> autofill?.run { if (focusState.isFocused) { - requestAutofillForNode(autofillNode) + requestAutofillForNode(autofillNodeKey) } else { - cancelAutofillForNode(autofillNode) + cancelAutofillForNode(autofillNodeKey) } } }, value = key.value, - onValueChange = { key.value = it }, + onValueChange = { + key.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, keyboardOptions = KeyboardOptions( autoCorrect = false, @@ -281,12 +311,12 @@ fun LoginPage( }, trailingIcon = { Row { - IconButton(onClick = { showPassword = !showPassword }) { + IconButton(onClick = { showCharsKey = !showCharsKey }) { Icon( imageVector = - if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, contentDescription = - if (showPassword) { + if (showCharsKey) { stringResource(R.string.show_password) } else { stringResource( @@ -309,29 +339,42 @@ fun LoginPage( IconButton(onClick = { dialogOpen = true }) { Icon( painter = painterResource(R.drawable.ic_qrcode), - null, + contentDescription = + stringResource( + R.string.login_with_qr_code, + ), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) } }, visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), keyboardActions = KeyboardActions( onGo = { if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) } if (key.value.text.isBlank()) { errorMessage = context.getString(R.string.key_is_required) } - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + processingLogin = false + errorMessage = + if (it != null) { + context.getString(R.string.invalid_key_with_message, it) + } else { + context.getString(R.string.invalid_key) + } } } }, @@ -347,39 +390,128 @@ fun LoginPage( Spacer(modifier = Modifier.height(10.dp)) - if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true + if (needsPassword.value) { + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNodePassword.boundingBox = coordinates.boundsInWindow() } - }, - ) - - Text(stringResource(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast.makeText( - context, - it, - Toast.LENGTH_LONG, + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodePassword) + } else { + cancelAutofillForNode(autofillNodePassword) + } + } + }, + value = password.value, + onValueChange = { + password.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, ) - .show() } - }, - proxyPort, - ) + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + processingLogin = false + errorMessage = + if (it != null) { + context.getString(R.string.invalid_key_with_message, it) + } else { + context.getString(R.string.invalid_key) + } + } + } + }, + ), + ) + + Spacer(modifier = Modifier.height(10.dp)) + + if (PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + }, + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } } } @@ -401,6 +533,7 @@ fun LoginPage( withStyle(clickableTextStyle) { pushStringAnnotation("openTerms", "") append(stringResource(R.string.terms_of_use)) + pop() } } @@ -442,23 +575,37 @@ fun LoginPage( errorMessage = context.getString(R.string.key_is_required) } - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + processingLogin = false + errorMessage = + if (it != null) { + context.getString(R.string.invalid_key_with_message, it) + } else { + context.getString(R.string.invalid_key) + } } } }, shape = RoundedCornerShape(Size35dp), modifier = Modifier.height(50.dp), ) { - Text( - text = stringResource(R.string.login), - modifier = Modifier.padding(horizontal = 40.dp), - ) + Row(modifier = Modifier.padding(horizontal = 40.dp)) { + if (processingLogin) { + LoadingAnimation() + Spacer(modifier = DoubleHorzSpacer) + } + Text(stringResource(R.string.login)) + } } } - if (PackageUtils.isAmberInstalled(context)) { + if (PackageUtils.isExternalSignerInstalled(context)) { Box(modifier = Modifier.padding(40.dp, 20.dp, 40.dp, 0.dp)) { Button( enabled = acceptedTerms.value, @@ -490,7 +637,7 @@ fun LoginPage( Spacer(modifier = Modifier.height(Size20dp)) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - Button( + OutlinedButton( onClick = onWantsToLogin, shape = RoundedCornerShape(Size35dp), modifier = Modifier.height(50.dp), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt index e34a0c7bf..cd37006e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -40,6 +40,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -93,7 +94,7 @@ fun SignUpPage() { @Composable fun SignUpPage( - accountViewModel: AccountStateViewModel, + accountStateViewModel: AccountStateViewModel, onWantsToLogin: () -> Unit, ) { val displayName = remember { mutableStateOf(TextFieldValue("")) } @@ -162,7 +163,7 @@ fun SignUpPage( } if (acceptedTerms.value && displayName.value.text.isNotBlank()) { - accountViewModel.login(displayName.value.text, useProxy.value, proxyPort.value.toInt()) { + accountStateViewModel.login(displayName.value.text, useProxy.value, proxyPort.value.toInt()) { errorMessage = context.getString(R.string.invalid_key) } } @@ -196,6 +197,7 @@ fun SignUpPage( withStyle(clickableTextStyle) { pushStringAnnotation("openTerms", "") append(stringResource(R.string.terms_of_use)) + pop() } } @@ -272,7 +274,7 @@ fun SignUpPage( } if (acceptedTerms.value && displayName.value.text.isNotBlank()) { - accountViewModel.newKey(useProxy.value, proxyPort.value.toInt(), displayName.value.text) + accountStateViewModel.newKey(useProxy.value, proxyPort.value.toInt(), displayName.value.text) } }, shape = RoundedCornerShape(Size35dp), @@ -292,7 +294,7 @@ fun SignUpPage( Spacer(modifier = Modifier.height(Size20dp)) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - Button( + OutlinedButton( onClick = onWantsToLogin, shape = RoundedCornerShape(Size35dp), modifier = Modifier.height(50.dp), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 7e0d21bb9..44eef1170 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 67f1cb8ac..0d10f938d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -116,6 +116,7 @@ val Size19Modifier = Modifier.size(19.dp) val Size20Modifier = Modifier.size(20.dp) val Size22Modifier = Modifier.size(22.dp) val Size24Modifier = Modifier.size(24.dp) +val Size25Modifier = Modifier.size(25.dp) val Size26Modifier = Modifier.size(26.dp) val Size30Modifier = Modifier.size(30.dp) val Size35Modifier = Modifier.size(35.dp) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 288893215..2fcdc92b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -217,6 +217,18 @@ val DarkLargeRelayIconModifier = .clip(shape = CircleShape) .background(DarkColorPalette.background) +val LightBottomIconModifier = + Modifier + .size(Size10dp) + .clip(shape = CircleShape) + .background(LightColorPalette.primary) + +val DarkBottomIconModifier = + Modifier + .size(Size10dp) + .clip(shape = CircleShape) + .background(DarkColorPalette.primary) + val RichTextDefaults = RichTextStyle().resolveDefaults() val MarkDownStyleOnDark = @@ -381,6 +393,9 @@ val ColorScheme.relayIconModifier: Modifier val ColorScheme.largeRelayIconModifier: Modifier get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier +val ColorScheme.bottomIconModifier: Modifier + get() = if (isLight) LightBottomIconModifier else DarkBottomIconModifier + val ColorScheme.chartStyle: ChartStyle get() { val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index 482985f5c..f30ebf75d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ed1e090c5..6242ab259 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -85,9 +85,12 @@ الرسائل الخاصة الرسائل العامة الموجز العام + البحث أضف Relay إسم العرض إسم العرض الخاص بي + اسمي المميز + مرحبا بك! اسم المستخدم اسم المستخدم الخاص بي ْعَنِّي @@ -126,6 +129,7 @@ إرسال رسالة مباشرة القيام بتحرير metadata للمستخدم متابعة + إرجاع المتابعة رفع الحظر نسخ معرف المستخدم إلغاء حظر عن مستخدم @@ -140,7 +144,14 @@ شروط الاستخدام يجب قبول شروط الإستخدام المفتاح مطلوب + يجب ادخال اسم تسجيل دخول + التسجيل + انشاء حساب + كيف ينبغي أن ندعوك؟ + ليس لديك حساب؟ + هل لديك حساب nostr بالفعل؟ + أنشئ حسابًا جديدًا إنشاء مفتاح جديد بإنتظار التحديثات جاري تحميل الحساب @@ -182,12 +193,6 @@ ضع علامة على جميع الرسائل/الاشعارات الجديدة كمقروء ضع علامة على جميع الرسائل/الاشعارات كمقروء قم بعمل نسخة احتياطية للمفاتيح - ## النصائح الرئيسية للنسخ الاحتياطي والسلامة - \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه حق الوصول إلى مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. - \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. - \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. - \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور. - مفتاحك السري (nsec) قد تم نسخه في الحافظة إنسخ مفتاحي السري فشلت المصادقة @@ -249,6 +254,7 @@ إزالة من المفضلات الخاصة إزالة من المفضلات العامة خدمة Wallet Connect + السماح لمفتاحك السري بدفع ال zaps بدون الحاجة الى لخروج من التطبيق (دع مفتاحك السري ٱمن و استخدم خادم خاص إن امكن) خادم Wallet Connect كلمة سر خدمة Wallet Connect إظهار كلمة السر @@ -293,6 +299,7 @@ المستقبل والجمهور لا يعرفان من الذي قام بإرسال الدفع لا يوجد أثر في نوستر، فقط على شبكة الLightning خادم الملفات + user@ او Lnaddres المرحلات الخاصة بك (NIP-95) إعداد Tor/Orbot الاتصال من خلال إعدادات Orbot الخاص بك @@ -303,6 +310,9 @@ قائمة المتابعة جميع المتابعات العالمي + قائمة الحسابات المكتومة + الرسائل الخاصة + التنبيه عند وصول رسالة خاصة من طرف %1$s ل %1$s تنبيه: @@ -347,6 +357,8 @@ المصادقة الدفع Cashu Token + تسخ الtoken + نسخ الtoken من المحفظة تسجيل الخروج سوف يؤدي الى جميع المعلومات المحلية الخاصة بك. تأكد من وجود نسخة احتياطية لمفاتحك السرية لتجنب فقدان حسابك. هل تريد الاستمرار؟ العلامات المُتابعة @@ -416,6 +428,12 @@ موافق فشل الوصول إلى %1$s: %2$s + الصفحة الرئيسية + البحث + خطأ في فتح تطبيق الموقِّع (signer app) + الكلمات المخفية + إخفاء كلمة أو جملة جديدة + حدد خيارا %1$s sats أرسلت الى محفظتك (الرسوم: sats %2$s ) تعذر جلب الinvoice من خوادم المستلم @@ -436,4 +454,31 @@ لاستخدام الإشعارات حمل أي تطبيق يدعم ال [Unified Push](https://unifiedpush.org/) مثل [Nfty](https://ntfy.sh/). بعد تثبيتك التطبيق قم باختياره من الإعدادات + العنوان + iPhone 13 + الشرط + الفئة + الموقع الجغرافيّ + كالجديد + مستعمل، لكن لا تظهر عليه أي ٱثار للاستعمال + بحالة جيدة + لديه بعض علامات الاستخدام السطحية + جيد + لا يزال في شكل مقبول وعملي + ملابس + اكسسوارات + إلكترونيات + أثاث + كتب + حيوانات أليفة + الرياضة + اللياقة البدنية + فن + الحرف + الصفحة الرئيسية + المكتب + طعام + أخرى + فشل في تحميل الوسائط + خطأ في التحميل: %1$s diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 45ac5238e..952082ed4 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -182,12 +182,6 @@ নতুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন ব্যাকআপ চাবিগুলি - ## চাবির ব্যাকআপ এবং নিরাপত্তা বিষয়ক টিপস - \n\nআপনার অ্যাকাউন্ট একটি ব্যক্তিগত চাবি দ্বারা সুরক্ষিত। চাবি হলো এলোমেলো অক্ষর ও সংখ্যার একটি দীর্ঘ তন্তু যা **nsec** দ্বারা শুরু হয়। আপনার ব্যক্তিগত চাবিটি পেলে যে কেউ আপনার পরিচয় ব্যবহার করে যেকোনো আধেয় প্রকাশ করতে পারবে। - \n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট কিংবা সফটওয়্যারে আপনার ব্যক্তিগত চাবিটি রাখবেন **না**। - \n- অ্যামেথিস্টের ডেভেলপাররা **কখনোই** আপনার কাছে আপনার ব্যক্তিগত চাবিটি জানতে চাইবে না। - \n- আপনার ব্যক্তিগত চাবির একটি নিরাপদ ব্যাকআপ **অবশ্যই** সংরক্ষণ করবেন কারণ এটি আপনার অ্যাকাউন্ট পুনরুদ্ধারে কাজে আসবে। আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহারের পরামর্শ দিই। - ব্যক্তিগত চাবি (nsec) ক্লিপবোর্ডে কপি করা হয়েছে আমার ব্যক্তিগত চাবিটি কপি করুন প্রমাণীকরণ ব্যর্থ হয়েছে। diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 2289f235a..5cd064b5e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -3,6 +3,7 @@ Nasměrujte na QR kód Zobrazit QR kód Profilový obrázek + Profilový obrázek Skenovat QR kód Zobrazit přesto Příspěvek byl označen jako nevhodný uživatelem @@ -43,6 +44,7 @@ Zvýšení zvýšeno Citovat + Rozštěpení Nová částka v sats Přidat "odpovídá na " @@ -138,12 +140,15 @@ Vymazat Logo aplikace nsec / npub / hex privátní klíč + heslo pro otevření klíče Zobrazit heslo Skrýt heslo Neplatný klíč + Neplatný klíč: %1$s "Souhlasím s " podmínkami použití Je vyžadováno přijetí podmínek + Heslo je vyžadováno Klíč je povinný Jméno je povinné Přihlásit se @@ -194,14 +199,17 @@ Označit všechny nové jako přečtené Označit všechny jako přečtené Zálohovat klíče - ## Tipy na zálohování a bezpečnost účtu - \n\nVáš účet je zabezpečen tajným klíčem. Klíč je dlouhý náhodný řetězec začínající **nsec1**. Každý, kdo má přístup k vašemu tajnému klíči, může publikovat obsah pod vaší identitou. - \n\n- **Nepoužívejte** svůj tajný klíč na webových stránkách nebo v softwaru, kterému nedůvěřujete. - \n- Vývojáři aplikace Amethyst vás **nikdy** nebudou žádat o váš tajný klíč. - \n- **Uchovávejte** si bezpečnou zálohu svého tajného klíče pro obnovení účtu. Doporučujeme používat správce hesel. - + ## Tipy pro zálohování klíčů a bezpečnost + \n\nVaše konto je zabezpečeno tajným klíčem. Klíč je dlouhá posloupnost znaků začínající s **nsec1**. Kdo má přístup k tomuto tajnému klíči, může přispívat a měnit vaši identitu. + \n\n- **Nedávejte** svůj tajný klíč na žádnou webovou stránku nebo do žádného softwaru, kterému nedůvěřujete. + \n- Vývojáři Amethystu nikdy **nebudou** žádat o váš tajný klíč. + \n- **Udržujte** bezpečnou zálohu vašeho tajného klíče pro obnovení účtu. Doporučujeme použití správce hesel. + Pro další zabezpečení můžete svůj klíč zašifrovat heslem. Tento klíč začíná s **ncryptsec1** a nelze ho použít bez vašeho hesla. + \n\nPokud zapomenete heslo, nebudete moci obnovit svůj klíč. + Nepodařilo se zašifrovat váš privátní klíč Tajný klíč (nsec) zkopírován do schránky Zkopírovat můj tajný klíč + Zašifrovat a zkopírovat můj tajný klíč Autentizace se nezdařila Biometrické údaje se nepodařilo ověřit vlastníka tohoto telefonu Biometrické údaje se nepodařilo ověřit vlastníka tohoto telefonu. Chyba: %1$s @@ -453,6 +461,7 @@ Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-24 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-24 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta. Aktivovat Veřejné + Nová veřejná nebo soukromá skupina Soukromé Pro Předmět @@ -469,6 +478,7 @@ Zobrazit náhledy URL Kdy načíst obrázek Kopírovat do schránky + Kopírovat npub do schránky Kopírovat URL do schránky Kopírovat ID poznámky do schránky Vytvořeno @@ -532,6 +542,7 @@ Nepodařilo se sestavit LNUrl z Lightning adresy \"%1$s\". Zkontrolujte nastavení uživatele Služba blesku příjemce v %1$s není k dispozici. Byla vypočtena z adresy blesku \"%2$s\". Chyba: %3$s. Zkontrolujte, zda je server aktivní a zda je adresa blesku správná Nepodařilo se vyřešit %1$s. Zkontrolujte, zda jste připojeni, zda je server aktivní a zda je adresa %2$s správná + Nepodařilo se vyřešit %1$s. Zkontrolujte, zda jste připojeni, zda je server aktivní a zda je adresa %2$s správná.\n\nVýjimka byla: %3$s Nepodařilo se načíst fakturu z %1$s Chyba při analýze JSON z Lightning adresy. Zkontrolujte uživatelovo bleskové nastavení URL adresa zpětného volání nebyla nalezena v konfiguraci serveru pro bleskovou adresu uživatele @@ -596,4 +607,58 @@ Server po nahrání neposkytl URL Nepodařilo se stáhnout nahraná média ze serveru Nelze připravit místní soubor k nahrání: %1$s + Přihlášení pomocí QR kódu + Trasa + Domů + Hledat + Objevit + Zprávy + Upozornění + Globální + Krátké + Bezpečnostní filtry + Nový příspěvek + Nové Shorts: obrázky nebo videa + Nová poznámka komunity + Otevřít všechny reakce pro tento příspěvek + Zavřít všechny reakce na tento příspěvek + Odpověď + Zvýšit nebo citovat + Olajkovat + Zap + Obrázek profilu %1$s + Relé %1$s + Rozbalit seznam relací + Volby poznámky + Výběr seznamu relé + Anketa + Vypnout anketu + Bitcoinová faktura + Zrušit Bitcoinovou fakturu + Zrušit prodej položky + Zap-sběr + Zrušit Zap-zběrku + Místo + Odstranit umístění + Zap splits + Zrušit rozdělení Zap + Přidat upozornění na obsah + Odstranit upozornění na obsah + Zobrazit npub jako QR kód + Neplatná adresa + Amethyst obdržel URI k otevření, ale tento URI byl neplatný: %1$s + Zapni vývojáře! + Váš příspěvek nám pomáhá dělat rozdíl. Každý sat se počítá! + Přispět nyní + představeno pro vás: + Tato verze byla představena pro vás: + Verze %1$s + Děkujeme! + Maximální limit + Omezené zápisy + Rozštěpováno od + FORK + Git repositář: %1$s + Internet: + Klon: diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4631cd18b..3f5c2d99d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -3,6 +3,7 @@ Zeigen Sie auf den QR Code QR Code anzeigen Profilbild + Dein Profilbild QR Code scannen Trotzdem anzeigen Der Beitrag wurde als unangemessen gekennzeichnet von @@ -43,6 +44,7 @@ Boost boosted Zitat + Fork Neuer Betrag in Sats Hinzufügen "Antworten auf " @@ -140,14 +142,17 @@ erie gespeichert Löschen App-Logo Nsec / Npub / Hex-Privatschlüssel + Passwort zum Öffnen des Schlüssels Passwort anzeigen Passwort ausblenden Ungültiger Schlüssel + Ungültiger Schlüssel: %1$s "Ich akzeptiere die " Nutzungsbedingungen Die Akzept anz der Bedingungen ist erforderlich + Passwort ist erforderlich Schlüssel ist erforderlich Ein Name wird benötigt Anmeldung @@ -198,13 +203,17 @@ anz der Bedingungen ist erforderlich Alle als neu markieren Alle als gelesen markieren Schlüssel sichern - ## Sicherung und Sicherheitshinweise für Schlüssel - \n\nIhr Konto ist durch einen geheimen Schlüssel geschützt. Der Schlüssel ist eine lange zufällige Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf Ihren geheimen Schlüssel hat, kann Inhalte unter Verwendung Ihrer Identität veröffentlichen. - \n\n- Geben Sie Ihren geheimen Schlüssel **nicht** auf Websites oder Software ein, denen Sie nicht vertrauen. + ## Schlüsselsicherheits-Tipps + \n\nIhr Konto ist durch einen geheimen Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen geheimen Schlüssel hat, kann Ihre Identität posten und ändern. + \n\n- Legen Sie Ihren geheimen Schlüssel **nicht** auf eine Website oder in eine Software, der Sie nicht vertrauen. \n- Die Entwickler von Amethyst werden Sie **niemals** nach Ihrem geheimen Schlüssel fragen. - \n- Bewahren Sie eine sichere Sicherungskopie Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. + \n- **Bewahren Sie** eine sichere Sicherung Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. + Für zusätzliche Sicherheit können Sie Ihren Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne Ihr Passwort nicht verwendet werden. + \n\nWenn Sie Ihr Passwort verlieren, können Sie Ihren Schlüssel nicht wiederherstellen. + Fehler beim Verschlüsseln Ihres privaten Schlüssels Geheimer Schlüssel (nsec) in die Zwischenablage kopiert Meinen geheimen Schlüssel kopieren + Verschlüsseln und kopieren Sie meinen geheimen Schlüssel Authentifizierung fehlgeschlagen Biometrie konnte den Besitzer dieses Telefons nicht authentifizieren Biometrie konnte den Besitzer dieses Telefons nicht authentifizieren. Fehler: %1$s @@ -457,6 +466,7 @@ anz der Bedingungen ist erforderlich Um diesen Modus zu aktivieren, muss Amethyst eine NIP-24-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-24 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet. Aktivieren Öffentlich + Neue öffentliche oder private Gruppe Privat An Betreff @@ -473,6 +483,7 @@ anz der Bedingungen ist erforderlich URL-Vorschauen anzeigen Wann Bilder geladen werden sollen In Zwischenablage kopieren + Npub in Zwischenablage kopieren URL in die Zwischenablage kopieren Notiz-ID in die Zwischenablage kopieren Erstellt am @@ -536,6 +547,7 @@ anz der Bedingungen ist erforderlich Konnte keine LNUrl aus der Lightning-Adresse \"%1$s\" zusammenstellen. Überprüfen Sie die Konfiguration des Benutzers Der Lightning-Service des Empfängers unter %1$s ist nicht verfügbar. Er wurde aus der Lightning-Adresse \"%2$s\" berechnet. Fehler: %3$s. Überprüfen Sie, ob der Server online ist und ob die Lightning-Adresse korrekt ist Konnte %1$s nicht auflösen. Überprüfen Sie, ob Sie verbunden sind, ob der Server online ist und ob die Lightning-Adresse %2$s korrekt ist + Konnte %1$s nicht auflösen. Überprüfen Sie, ob Sie verbunden sind, ob der Server online ist und ob die Lightning-Adresse %2$s korrekt ist.\n\nAusnahme war: %3$s Konnte Rechnung nicht von %1$s abholen Fehler beim analysieren von JSON aus der Lightning-Adresse. Überprüfen Sie die Lightning-Konfiguration des Benutzers Callback-URL nicht in der Serverkonfiguration der Lightning-Adresse des Benutzers gefunden @@ -600,4 +612,58 @@ anz der Bedingungen ist erforderlich Der Server hat nach dem Hochladen keine URL angegeben Hochgeladene Medien konnten nicht vom Server heruntergeladen werden Lokale Datei konnte nicht zum Hochladen vorbereitet werden: %1$s + Einloggen mit QR-Code + Route + Startseite + Suche + Entdecken + Nachrichten + Benachrichtigungen + Global + Kurzfilme + Sicherheitsfilter + Neuer Beitrag + Neue Kurzfilme: Bilder oder Videos + Neue Community-Notiz + Alle Reaktionen auf diesen Beitrag öffnen + Alle Reaktionen auf diesen Beitrag schließen + Antworten + Boosten oder Zitieren + Gefällt mir + Zap + Profilbild von %1$s + %1$s weiterleiten + Rela-Liste erweitern + Notizoptionen + Rela-Listenauswahl + Umfrage + Umfrage deaktivieren + Bitcoin-Rechnung hinzufügen + Bitcoin-Rechnung abbrechen + Verkauf eines Artikels abbrechen + Zapraiser hinzufügen + Zapraiser abbrechen + Ort hinzufügen + Ort entfernen + Zap-Aufteilung hinzufügen + Zap-Aufteilung abbrechen + Inhaltswarnung hinzufügen + Inhaltswarnung entfernen + Npub als QR-Code anzeigen + Ungültige Adresse + Amethyst hat eine URI zum Öffnen erhalten, aber diese URI war ungültig: %1$s + Zap die Entwickler! + Deine Spende hilft uns, einen Unterschied zu machen. Jeder Sat zählt! + Jetzt spenden + wurde Ihnen präsentiert von: + Diese Version wurde Ihnen präsentiert von: + Version %1$s + Vielen Dank! + Maximallimit + Eingeschränkte Schriften + Geforkt von + FORK + Git Repository: %1$s + Internet: + Klonen: diff --git a/app/src/main/res/values-el-rGR/strings.xml b/app/src/main/res/values-el-rGR/strings.xml index 9f50c750f..8cd59e916 100644 --- a/app/src/main/res/values-el-rGR/strings.xml +++ b/app/src/main/res/values-el-rGR/strings.xml @@ -185,12 +185,6 @@ Σήμανση όλων των νέων δημοσιεύσεων ως αναγνωσμένων Σήμανση όλων ως αναγνωσμένα Δημιουργία Αντιγράφου Ασφαλείας των Προσωπικών σας Κλειδιών - ## Οδηγίες για τη δημιουργία Αντιγράφου Ασφαλείας του \"Μυστικού Κλειδιού\" σας. - \n\nΟ Λογαριασμός σας προστατεύεται από το \"Μυστικό Κλειδί\" σας. Το \"Μυστικό Κλειδί\"σας αποτελείται από μια μεγάλη σειρά αλφαριθμητικών χαρακτήρων και ξεκινάει πάντα με \"nsec1\". -Οποιοσδήποτε έχει πρόσβαση στο \"Μυστικό Κλειδί\" σας μπορεί να δημοσιεύσει περιεχόμενο και να έχει πλήρη πρόσβαση στον λογαριασμό σας. - \n\n- Μην αντιγράφετε *Ποτέ* το \"Μυστικό Κλειδί\" σας σε ιστοσελίδα ή λογισμικό που δεν εμπιστεύεστε. - \n- Οι προγραμματιστές του Amethyst δεν θα σας ζητήσουν *Ποτέ* το \"Μυστικό Κλειδί\" σας. - \n- Δημιουργείστε ένα Ασφαλές Αντίγραφο του \"Μυστικού Κλειδιού\" σας για να μπορείτε να επαναφέρετε τον λογαριασμό σας. Προτείνουμε να χρησιμοποιήσετε ένα πρόγραμμα διαχειρίσης κωδικών. Το \"Μυστικό Κλειδί\" (nsec) αντιγράφηκε στο πρόχειρο Αντιγραφή του \"Μυστικού Κλειδιού\" μου Αποτυχία ταυτοποίησης diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 66a570486..1d66bcd8f 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -1,2 +1,15 @@ - + + Point to the QR Code + Show QR + Profile Image + Scan QR + Show Anyway + Post was muted or reported by + Event is loading or can\'t be found in your relay list + Channel Image + Referenced event not found + Could not decrypt the message + Group Picture + Explicit Content + diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 0adee9665..90b23a36d 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -182,12 +182,6 @@ Marki ĉiujn Novajn legitaj Marki ĉiujn legitajn Sekurkopii Ŝlosilojn - ## Sekurkopio de Ŝlosiloj kaj Rekomendoj de Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa hazarda literĉeno komencata kun **nsec1**. Ĉiu, kiu havas aliron al via sekreta ŝlosilo, povas publikigi enhavon per via identeco. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajna retejo aŭ programaro, kiun vi ne fidas. - \n- La programistoj de Amethyst **neniam** petos de vi vian sekretan ŝlosilon. - \n- **Ja** konservu sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortmanaĝilon. - Sekreta ŝlosilo (nsec) kopiita al tondujo Kopii mian sekretan ŝlosilon Aŭtentikigo malsukcesis diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 5951c0b6d..62a2a8e82 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -2,16 +2,21 @@ Apunta al código QR Mostrar el QR - Imagen de Perfil + Imagen de perfil + Tu imagen de perfil Escanear el QR Mostrar de todos modos Post marcado como inapropiado por + El evento se está cargando o no se puede encontrar en la lista de relés Imagen del canal Evento referenciado no encontrado + No se pudo desencriptar el mensaje Imagen de grupo Contenido explícito + Spam Suplantación de identidad Comportamiento ilegal + Otro Desconocido Icono del transmisor Autor desconocido @@ -19,25 +24,39 @@ Copiar PubKey del usuario Copiar ID de la nota Transmisión + Poner marca de tiempo + Marca de tiempo: confirmaciones pendientes + OTS: pendiente + Solicitar eliminación + Bloquear / Reportar Reportar Spam / Estafa Reportar suplantación de identidad Reportar contenido explícito Reportar comportamiento ilegal - Inicie sesión con una clave privada para poder responder - Inicie sesión con una clave privada para poder publicar posts - Inicie sesión con una clave privada para dar me gusta a los posts - Falta la configuración de Zaps por defecto. Mantén pulsado unos segundos para cambiarla - Inicie sesión con una clave privada para poder enviar Zaps - Total vistas + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder responder + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones. + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones. + Falta la configuración de zaps por defecto. Mantén pulsado unos segundos para cambiarla + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder enviar zaps. + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder seguir a otros usuarios. + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios. + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder ocultar una palabra o frase. + Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder mostrar una palabra o frase. + Zaps + Total de visualizaciones Impulsar - Cita - Nueva cantidad en Sats + Impulsada + Citar + Bifurcar + Nueva cantidad en sats Añadir "respondiendo a " " y " - "en canal " + "en el canal " Banner de perfil + El pago se ha realizado correctamente + Error al analizar el mensaje de error " Siguiendo" " Seguidores" Perfil @@ -49,8 +68,8 @@ Donación Lightning Nota al receptor ¡Muchas gracias! - Cantidad en Sats - Enviar Sats + Cantidad en sats + Enviar sats "Error al analizar la vista previa de %1$s : %2$s" "Imagen de vista previa para %1$s" Nuevo canal @@ -60,22 +79,25 @@ Descripción "Sobre nosotros… " ¿Qué tienes en mente? - Enviar + Publicar Guardar Crear Cancelar No se pudo cargar la imagen - Dirección del transmisor + Dirección del relé Publicaciones + Bytes Errores Tus noticias Fuente de mensajes privados Fuente de chat público Fuente global Fuente de búsqueda - Anadir transmisor + Añadir transmisor Nombre para mostrar Mi nombre para mostrar + Avestruz Maravillosa + ¡Te damos la bienvenida, Avestruz! Nombre de usuario Mi nombre de usuario Sobre mí @@ -83,15 +105,15 @@ URL del banner Dirección web Dirección LN - Dirección LN (antiguo) + Dirección LN (antigua) Imagen guardada en la galería Error al guardar la imagen Cargar imagen Cargando… El usuario no tiene una configuración de dirección Lightning para recibir sats "responde aquí… " - Copia la nota-ID para compartir - Copia la nota-ID del canal + Copia el ID de la nota al portapapeles para compartirla en Nostr + Copiar el ID del canal (nota) al portapapeles Edita los metadatos del canal Unirse Conocidos @@ -107,30 +129,42 @@ " Retransmisores" Sitio web Dirección Lightning - Copia la ID de Nsec (su contraseña) en el portapapeles para hacer una copia de seguridad + Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Copiar clave privada al portapapeles Copia la clave pública al portapapeles para compartir - Copie la clave pública (NPub) al portapapeles + Copiar la clave pública (NPub) al portapapeles Enviar un mensaje directo Edita los metadatos del usuario Seguir + Seguir también Desbloquear Copiar ID de usuario Desbloquear usuario "npub, hex, username " Limpiar Logo de la app - nsec / npub / hex private key + nsec o npub + contraseña para abrir la clave Mostrar contraseña Ocultar contraseña - Clave invalida + Clave inválida + Clave inválida: %1$s "Acepto los " términos de uso Se requiere la aceptación de los términos + Se requiere una contraseña Se requiere la clave + Se requiere un nombre Acceso + Registrarse + Crear cuenta + ¿Cómo debemos llamarte? + ¿No tienes una cuenta de Nostr? + ¿Ya tienes una cuenta de Nostr? + Crear una cuenta nueva Generar una nueva clave Cargando el tablón… + Cargando cuenta "Error al cargar las respuestas: " Intentar otra vez Tablón vacío @@ -141,20 +175,25 @@ cambió el nombre del chat a descripción a e imagen a - Dejar + Salir Dejar de seguir Canal creado "La información del canal cambió a" Chat público publicaciones recibidas Eliminar + Automático traducido de a Mostrar en %1$s primero Traducir siempre a %1$s Nunca traducir desde %1$s + Dirección de Nostr nunca ahora + h + m + d Desnudos Blasfemias / Discurso de odio Denuncia discurso de odio @@ -164,9 +203,23 @@ Marcar los nuevos como leídos Marcar todos como leídos Claves de respaldo + ## Consejos sobre la protección y la copia de seguridad de las claves + \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. + \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. + \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. + \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. + \n\nSi pierdes la contraseña, no podrás recuperar tu clave. + + Error al cifrar la clave privada Clave secreta (nsec) copiada al portapapeles Copiar mi clave secreta + Cifrar y copiar mi clave secreta Autenticación fallida + El sistema biométrico no pudo autenticar al propietario de este teléfono + El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s + Error "Credo por %1$s" "Imagen del Badge para %1$s" Has recibido un nuevo Badge @@ -175,7 +228,16 @@ \@npub del autor copiado al portapapeles ID de nota copiada (@note1) al portapapeles Seleccionar texto - Selecciona + "<Unable to decrypt private message>\n\nTe citaron en una conversación privada o encriptada entre %1$s y %2$s." + Agregar cuenta nueva + Cuentas + Seleccionar cuenta + Agregar cuenta nueva + Cuenta activa + Tiene clave privada + Solo de lectura, sin clave privada + Volver + Seleccionar Compartir enlace del navegador Compartir ID del autor @@ -186,4 +248,426 @@ Seguir Solicitar eliminación Amethyst solicitará que se elimine su nota de los relays a los que está conectado actualmente. No hay garantía de que su nota se elimine permanentemente de esos relays, o de otros relays donde pueda almacenarse. + Bloquear + Eliminar + Bloquear + Reportar + Eliminar + No mostrar de nuevo + Spam o estafas + Groserías o conducta que incita al odio + Suplantación de identidad malintencionada + Desnudos o contenido gráfico + Comportamiento ilegal + Si bloqueas a un usuario, se ocultará su contenido en tu app. Tus notas todavía son visibles públicamente, incluso para las personas que bloquees. Los usuarios bloqueados aparecen en la pantalla \"Filtros de seguridad\". + + Reportar abuso + Todos los reportes publicados serán visibles públicamente. + Si quieres puedes agregar más contexto al reporte… + Contexto adicional + Motivo + Selecciona un motivo… + Publicar reporte + Bloquear y reportar + Bloquear + Marcadores + Marcadores privados + Marcadores públicos + Agregar a marcadores privados + Agregar a marcadores públicos + Eliminar de marcadores privados + Eliminar de marcadores públicos + Servicio de conexión a monedero + Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. + Clave pública de conexión a monedero + Relé de conexión a monedero + Secreto de conexión a monedero + Clave secreta de conexión a monedero + clave privada nsec / hex + Destinar cantidad en sats + Publicar encuesta + Campos obligatorios: + Destinatarios de zaps + Descripción de encuesta principal… + Opción %s + Descripción de opción de encuesta + Campos opcionales: + Zap mínimo + Zap máximo + Consenso + (0–100)% + Cerrar después de + días + No se puede votar + Encuesta cerrada para votos nuevos + Monto del zap + Solo se permite un voto por usuario en este tipo de encuesta + "Buscando evento %1$s" + Agregar un mensaje público + Agregar un mensaje privado + Agregar un mensaje con factura + ¡Gracias por tu trabajo! + Crear y añadir + Los autores de encuestas no pueden votar en ellas. + ¿Qué significa esto? + Este contenido es el mismo desde la publicación + Este contenido ha cambiado. Es posible que el autor no haya visto o aprobado el cambio. + Añadir imagen + Añadir vídeo + Añadir documento + Añadir al mensaje + Descripción del contenido + Un bote azul en una playa de arena blanca al atardecer + Tipo de zap + Tipo de zap para todas las opciones + Público + Todos pueden ver la transacción y el mensaje + Privado + El remitente y el destinatario pueden verse entre sí y leer el mensaje + Anónimo + El receptor y el público no saben quién envió el pago + Non-Zap + No hay rastro en Nostr, solo en Lightning + Servidor de archivos + Dirección o @usuario de Lightning + Tus relés (NIP-95) + Los archivos están alojados en tus relés. Nuevo NIP: comprueba si son compatibles. + Configuración de Tor/Orbot + Conéctate a través de tu configuración de Orbot + ¿Desconectarse de tu Orbot/Tor? + Los datos se transferirán de inmediato en la red normal + + No + Lista de seguidos + Todos los seguidos + Global + Lista de silenciados + ## Conéctate a través de Tor con Orbot + \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). + \n2. Inicia Orbot. + \n3. En Orbot, comprueba el puerto Socks. La opción predeterminada es 9050. + \n4. Si es necesario, cambia el puerto en Orbot. + \n5. Configura el puerto Socks en la pantalla. + \n6. Presiona y activa el botón para usar Orbot como proxy. + + Puerto Socks de Orbot + Número de puerto inválido + Usar Orbot + Desconectar Tor/Orbot + Mensajes privados + Te notifica cuando llega un mensaje privado + Zaps recibidos + Te notifica cuando alguien te zapea + %1$s sats + De %1$s + por %1$s + Notificar: + Unirse a la conversación + ID de usuario o grupo + npub, nevent o hexadecimal + Crear + Unirse + Hoy + Advertencia de contenido + Esta publicación incluye contenido delicado que algunas personas pueden considerar ofensivo o perturbador. + Ocultar siempre el contenido delicado + Mostrar siempre el contenido delicado + Mostrar siempre advertencias sobre contenido + Recomendaciones: + Filtrar el spam de desconocidos + Avisar cuando las publicaciones tengan reportes de tus seguidos + Nuevo símbolo de reacción + No hay tipos de reacción preseleccionados para este usuario. Deja presionado el botón de corazón para cambiarlos. + Zapraiser + Añade una meta de sats para recaudar por esta publicación. Los clientes compatibles pueden mostrarla como una barra de progreso para incentivar las donaciones. + Cantidad objetivo en sats + Zapraiser en %1$s. %2$s sats hasta la meta + Relé de lectura + Relé de escritura + Se ha producido un error al intentar obtener información sobre el relé de %1$s + Propietario + Versión + Software + Contacto + NIP compatibles + Tasas de admisión + URL de pagos + Limitaciones + Países + Idiomas + Etiquetas + Política de publicación + Longitud del mensaje + Suscripciones + Filtros + Longitud del ID de la suscripción + Prefijo mínimo + Etiquetas de evento máximas + Longitud del contenido + PoW mínima + Auth + Pago + Token de Cashu + Canjear + Enviar a monedero de zaps + Abrir en monedero de Cashu + Copiar token + La dirección de Lightning no se ha configurado + Token copiado al portapapeles + EN VIVO + FUERA DE LÍNEA + TERMINÓ + PROGRAMADO + La transmisión en vivo está fuera de línea + La transmisión en vivo ha terminado + Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar? + Etiquetas seguidas + Relés + Mercado + En vivo + Comunidad + Chats + Publicaciones aprobadas + Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. + Esta comunidad no tiene descripción. Habla con el propietario para agregar una. + Contenido delicado + Agrega una advertencia de contenido sensible antes de mostrarlo + Configuración + Siempre + Solo Wi-Fi + Nunca + Sistema + Claro + Oscuro + Preferencias de la aplicación + Idioma + Tema + Vista previa de imagen + Reproducción de vídeo + Vista previa de URL + Desplazamiento inmersivo + Ocultar barras de navegación al desplazarse + Cargar imagen + Spammers + Silenciado. Hacer clic para reactivar el sonido. + Sonido activado. Hacer clic para silenciar. + Buscar grabaciones locales y remotas + La dirección de Nostr se verificó + No se pudo verificar la dirección de Nostr + Verificando dirección de Nostr + Seleccionar o deseleccionar todo + Predeterminados + Seleccionar un relé para continuar + Reenviar zaps a: + Los clientes compatibles reenviarán los zaps a la dirección de Lightning o al perfil de usuario indicados a continuación en lugar de a los tuyos. + Revelar ubicación como + Agrega un Geohash de tu ubicación al mensaje. El público sabrá que te encuentras a menos de 5 km (3 mi) de la ubicación actual. + Agrega una advertencia de contenido delicado antes de mostrarlo. Esto es ideal para cualquier contenido NSFW o que a algunas personas les pueda resultar ofensivo o perturbador. + Función nueva + La activación de este modo requiere que Amethyst envíe un mensaje NIP-24 (mensajes directos sellados, de grupo y “GiftWrapped\"). NIP-24 es nuevo y la mayoría de los clientes aún no lo han implementado. Comprueba que el destinatario use un cliente compatible. + Activar + Público + Nuevo grupo público o privado + Privado + Para + Asunto + Tema de la conversación + "\@Usuario1, @Usuario2, @Usuario3" + Miembros de este grupo + Explicación para los miembros + Cambio de nombre para los nuevos objetivos. + Pegar desde el portapapeles + Para la interfaz de la aplicación + Tema oscuro, claro o del sistema + Cargar automáticamente imágenes y GIF + Reproducir automáticamente videos y GIF + Mostrar vistas previas de URL + Cuándo cargar imágenes + Copiar al portapapeles + Copiar npub al portapapeles + Copiar URL al portapapeles + Copiar ID de la nota al portapapeles + Creado + Reglas + Iniciar sesión con Amber + Actualiza tu estado + Error al analizar el mensaje de error + Los votos se calculan según la cantidad de zaps. Puedes establecer una cantidad mínima para evitar spammers y una máxima para que los zappers grandes no puedan dominar la encuesta. Usa la misma cantidad en ambos campos para asegurarte de que cada voto tenga el mismo valor. Déjalos vacíos para aceptar cualquier cantidad. + No se pudo enviar el zap + Mensaje para el usuario + OK + No se pudo llegar a %1$s: %2$s + No se pudo llegar a %1$s: %2$s + Error al analizar el resultado de %1$s: %2$s + %1$s falló con el código %2$s + Activo para: + Inicio + Mensajes directos + Chats + Global + Búsqueda + Dividir y reenviar zaps + Los clientes compatibles dividirán y reenviarán los zaps a los usuarios que se agreguen aquí en lugar de enviártelos a ti. + Buscar y agregar usuario + Nombre de usuario o para mostrar + Lightning no está configurado + El usuario %1$s no tiene una dirección de Lightning configurada para recibir sats. + Porcentaje + 25 + Dividir zaps con + Reenviar zaps a + No se encontraron billeteras de Lightning + Pagado + Monedero %1$s + Error al abrir la aplicación firmante + No se pudo encontrar la aplicación firmante. Comprueba si no se desinstaló la aplicación. + Solicitud de firma rechazada + Asegúrate de que la aplicación firmante haya autorizado esta transacción. + No se encontraron monederos para pagar una factura de Lightning (error: %1$s). Instala un monedero de Lightning para usar zaps. + No se encontraron monederos para pagar una factura de Lightning. Instala un monedero de Lightning para usar zaps. + Palabras ocultas + Ocultar nueva palabra o frase + Imagen de perfil + Mostrar imágenes de perfil + Seleccionar una opción + No se pudo pagar la factura + No se pudo retirar + No se pudo configurar Wallet Connect + Error al analizar la cadena de conexión NIP-47. Comprueba si esto es correcto con el proveedor del monedero: %1$s. Error: %2$s + Error al analizar la cadena de conexión NIP-47. Comprueba si esto es correcto con el proveedor del monedero: %1$s. + No se pudo canjear Cashu + La Mint proporcionó el siguiente mensaje de error: %1$s + Los tokens de Cashu ya se gastaron. + Cashu recibido + Se enviaron %1$s sats a tu monedero (comisión: %2$s sats). + No se encontró ningún monedero de Cashu compatible en el sistema + No se puede obtener la factura de los servidores del destinatario + El proveedor de conexión de monedero devolvió el siguiente error: %1$s + No se pudo conectar a Tor + No está disponible la descarga de documentos del relé + No se pudo ensamblar la LNUrl desde la dirección de Lightning \"%1$s\". Comprueba la configuración del usuario. + El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning \"%2$s\". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta. + No se pudo resolver %1$s. Comprueba si tienes conexión, si el servidor está activo y si la dirección de Lightning \"%2$s\" es correcta. + No se pudo resolver %1$s. Comprueba si tienes conexión, si el servidor está activo y si la dirección de Lightning \"%2$s\" es correcta..\n\nLa excepción fue: %3$s + No se pudo obtener la factura de %1$s + Error al analizar JSON desde la dirección de Lightning. Comprueba la configuración de Lightning del usuario. + No se encontró la URL de devolución de llamada en la configuración del servidor de la dirección de Lightning del usuario. + Error al analizar JSON desde la recuperación de factura de la dirección de Lightning. Comprueba la configuración de Lightning del usuario. + Cantidad de factura incorrecta (%1$s sats) de %2$s. Debería haber sido %3$s. + No se pudo crear una factura de Lightning antes de enviar el zap. El monedero de Lightning del destinatario envió el siguiente error: %1$s. + No se pudo crear una factura de Lightning antes de enviar el zap. No se encontró el elemento pr en el JSON resultante. + Usuario de solo lectura + No hay reacciones configuradas + Seleccionar una aplicación UnifiedPush + Notificación push + De aplicaciones UnifiedPush instaladas + + Desactiva las notificaciones push + Usa la app %1$s + Configuración de notificaciones push + Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Nfty](https://ntfy.sh/). + Tras la instalación, selecciona la aplicación que deseas usar en la configuración. + + Mensaje de %1$s + Conversación + Envía un mensaje al vendedor + Hola, %1$s, ¿esto todavía está disponible? + Hola, ¿esto todavía está disponible? + Vender un artículo + Título + iPhone 13 + Estado + Categoría + Precio (en sats) + 1000 + Ubicación + Ciudad, estado, país. + Nuevo + Producto totalmente nuevo, en la caja original. + Como nuevo + Usado, pero sin señales de uso. + Bueno + Tiene algunas marcas de uso superficiales. + Aceptable + Todavía está en forma aceptable y funcional. + Ropa + Accesorios + Artículos electrónicos + Muebles + Objetos de colección + Libros + Mascotas + Deportes + Acondicionamiento físico + Arte + Artesanías + Hogar + Oficina + Comida + Varios + Otros + Error al subir contenido multimedia + No se pudo abrir el archivo comprimido + Error al comprimir el contenido multimedia: %1$s + Error de carga: %1$s + El servidor no proporcionó una URL después de la carga + No se pudo descargar el contenido cargado desde el servidor + No se pudo preparar el archivo local para cargar: %1$s + Iniciar sesión con código QR + Ruta + Inicio + Buscar + Descubrir + Mensajes + Notificaciones + Global + Cortos + Filtros de seguridad + Nueva publicación + Nuevos cortos: imágenes o vídeos + Nueva nota comunitaria + Abrir todas las reacciones a esta publicación + Cerrar todas las reacciones a esta publicación + Responder + Impulsar o citar + Me gusta + Zap + Imagen de perfil de %1$s + Relé %1$s + Ampliar lista de relés + Opciones de notas + Selector de lista de relés + Encuesta + Desactivar encuesta + Factura de Bitcoin + Cancelar factura de Bitcoin + Cancelar la venta de un artículo + Zapraiser + Cancelar zapraiser + Ubicación + Eliminar ubicación + Divisiones de zaps + Cancelar división de zap + Añadir advertencia de contenido + Eliminar advertencia de contenido + Mostrar npub como código QR + Dirección inválida + Amethyst recibió un URI para abrir pero era inválido: %1$s + ¡Zapea a los desarrolladores! + Tu donación nos ayuda a marcar la diferencia. ¡Cada sat cuenta! + Donar ahora + fue posible gracias a: + Esta versión fue posible gracias a: + Versión %1$s + ¡Gracias! + Límite máximo + Escrituras restringidas + Bifurcación de + BIFURCACIÓN + Repositorio Git: %1$s + Web: + Clon: + OTS: %1$s + Prueba de marca de tiempo + Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml index 0cc233fe2..2852fb8e8 100644 --- a/app/src/main/res/values-es-rMX/strings.xml +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -3,6 +3,7 @@ Apunta al código QR Mostrar QR Imagen de perfil + Tu imagen de perfil Escanear código QR Mostrar de todos modos La publicación fue reportada por @@ -15,6 +16,7 @@ Spam Suplantación de identidad Comportamiento ilegal + Otro Desconocido Ícono de relé Autor desconocido @@ -22,6 +24,9 @@ Copiar llave pública del usuario Copiar ID de la nota Transmisión + Poner marca de tiempo + Marca de tiempo: confirmaciones pendientes + OTS: pendiente Solicitar eliminación Bloquear / Reportar @@ -39,10 +44,11 @@ Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder ocultar una palabra o frase. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder mostrar una palabra o frase. Zaps - Ver recuento + Total de visualizaciones Impulsar Impulsada - Citar + Cita + Bifurcar Nueva cantidad en sats Agregar "respondiendo a " @@ -82,7 +88,7 @@ Publicaciones Bytes Errores - Feed de inicio + Tus noticias Feed de mensajes privados Feed del chat público Feed global @@ -138,12 +144,15 @@ Borrar Logo de la app nsec o npub + contraseña para abrir la clave Mostrar contraseña Ocultar contraseña Clave inválida - "Acepto las " + Clave inválida: %1$s + "Acepto los " condiciones de uso Es obligatorio aceptar las condiciones + Se requiere una contraseña Se requiere la clave Se requiere un nombre Iniciar sesión @@ -194,14 +203,19 @@ Marcar todos los nuevos como leídos Marcar todos como leídos Respaldar claves - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. Esta clave es una cadena aleatoria y larga que empieza por **nsec1**. Cualquiera que tenga acceso a tu clave secreta puede publicar contenido usando tu identidad. + ## Consejos sobre la protección y la copia de seguridad de las claves + \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. + \n\nSi pierdes la contraseña, no podrás recuperar tu clave. + + Error al cifrar la clave privada Clave secreta (nsec) copiada al portapapeles Copiar mi clave secreta + Cifrar y copiar mi clave secreta Error de autenticación El sistema biométrico no pudo autenticar al propietario de este teléfono El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s @@ -346,7 +360,7 @@ Te notifica cuando alguien te zapea %1$s sats De %1$s - para %1$s + por %1$s Notificar: Unirse a la conversación ID de usuario o grupo @@ -367,7 +381,7 @@ Zapraiser Agrega una meta de sats para recaudar por esta publicación. Los clientes compatibles pueden mostrarla como una barra de progreso para incentivar las donaciones. Cantidad objetivo en sats - Zapraiser a %1$s. %2$s sats hasta la meta + Zapraiser en %1$s. %2$s sats hasta la meta Relé de lectura Relé de escritura Se produjo un error al intentar obtener información sobre el relé de %1$s @@ -453,6 +467,7 @@ La activación de este modo requiere que Amethyst envíe un mensaje NIP-24 (mensajes directos sellados, de grupo y “GiftWrapped\"). NIP-24 es nuevo y la mayoría de los clientes aún no lo han implementado. Comprueba que el destinatario use un cliente compatible. Activar Público + Nuevo grupo público o privado Privado Para Asunto @@ -469,6 +484,7 @@ Mostrar vistas previas de URL Cuándo cargar imágenes Copiar al portapapeles + Copiar npub al portapapeles Copiar URL al portapapeles Copiar ID de la nota al portapapeles Creado @@ -524,7 +540,7 @@ Los tokens de Cashu ya se gastaron. Cashu recibido Se enviaron %1$s sats a tu billetera (comisión: %2$s sats). - No se encontró ninguna billetera Cashu compatible en el sistema + No se encontró ninguna billetera de Cashu compatible en el sistema No se puede obtener la factura de los servidores del destinatario El proveedor de conexión de billetera devolvió el siguiente error: %1$s No se pudo conectar a Tor @@ -532,6 +548,7 @@ No se pudo ensamblar la LNUrl desde la dirección de Lightning \"%1$s\". Comprueba la configuración del usuario. El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning \"%2$s\". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta. No se pudo resolver %1$s. Comprueba si tienes conexión, si el servidor está activo y si la dirección de Lightning \"%2$s\" es correcta. + No se pudo resolver %1$s. Comprueba si tienes conexión, si el servidor está activo y si la dirección de Lightning \"%2$s\" es correcta..\n\nLa excepción fue: %3$s No se pudo obtener la factura de %1$s Error al analizar JSON desde la dirección de Lightning. Comprueba la configuración de Lightning del usuario. No se encontró la URL de devolución de llamada en la configuración del servidor de la dirección de Lightning del usuario. @@ -596,4 +613,61 @@ El servidor no proporcionó una URL después de la subida No se pudo descargar el contenido subido desde el servidor No se pudo preparar el archivo local para subir: %1$s + Iniciar sesión con código QR + Ruta + Inicio + Buscar + Descubrir + Mensajes + Notificaciones + Global + Cortos + Filtros de seguridad + Nueva publicación + Nuevos cortos: imágenes o videos + Nueva nota comunitaria + Abrir todas las reacciones a esta publicación + Cerrar todas las reacciones a esta publicación + Responder + Impulsar o citar + Me gusta + Zap + Imagen de perfil de %1$s + Relé %1$s + Ampliar lista de relés + Opciones de notas + Selector de lista de relés + Encuesta + Desactivar encuesta + Factura de Bitcoin + Cancelar factura de Bitcoin + Cancelar la venta de un artículo + Zapraiser + Cancelar zapraiser + Ubicación + Eliminar ubicación + Divisiones de zaps + Cancelar división de zap + Agregar advertencia de contenido + Eliminar advertencia de contenido + Mostrar npub como código QR + Dirección inválida + Amethyst recibió un URI para abrir pero era inválido: %1$s + ¡Zapea a los desarrolladores! + Tu donación nos ayuda a marcar la diferencia. ¡Cada sat cuenta! + Donar ahora + fue posible gracias a: + Esta versión fue posible gracias a: + Versión %1$s + ¡Gracias! + Límite máximo + Escrituras restringidas + Bifurcación de + BIFURCACIÓN + Repositorio Git: %1$s + Web: + Clon: + OTS: %1$s + Prueba de marca de tiempo + Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index cea4db84d..09a021900 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -3,6 +3,7 @@ Apunta al código QR Mostrar QR Imagen de perfil + Tu imagen de perfil Escanear código QR Mostrar de todos modos La publicación fue reportada por @@ -15,6 +16,7 @@ Spam Suplantación de identidad Comportamiento ilegal + Otro Desconocido Ícono de relé Autor desconocido @@ -22,6 +24,9 @@ Copiar ID del autor Copiar ID de la nota Transmisión + Poner marca de tiempo + Marca de tiempo: confirmaciones pendientes + OTS: pendiente Solicitar eliminación Bloquear / Reportar @@ -39,10 +44,11 @@ Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder ocultar una palabra o frase. Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder mostrar una palabra o frase. Zaps - Ver recuento + Total vistas Impulsar Impulsada - Citar + Cita + Bifurcar Nueva cantidad en sats Agregar "respondiendo a " @@ -82,7 +88,7 @@ Publicaciones Bytes Errores - Feed de inicio + Tus noticias Feed de mensajes privados Feed del chat público Feed global @@ -138,12 +144,15 @@ Borrar Logo de la app nsec o npub + contraseña para abrir la clave Mostrar contraseña Ocultar contraseña Clave inválida - "Acepto las " + Clave inválida: %1$s + "Acepto los " condiciones de uso Es obligatorio aceptar las condiciones + Se requiere una contraseña Se requiere la clave Se requiere un nombre Iniciar sesión @@ -194,14 +203,19 @@ Marcar todos los nuevos como leídos Marcar todos como leídos Respaldar claves - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. Esta clave es una cadena aleatoria y larga que empieza por **nsec1**. Cualquiera que tenga acceso a tu clave secreta puede publicar contenido usando tu identidad. + ## Consejos sobre la protección y la copia de seguridad de las claves + \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. + \n\nSi pierdes la contraseña, no podrás recuperar tu clave. + + Error al cifrar la clave privada Clave secreta (nsec) copiada al portapapeles Copiar mi clave secreta + Cifrar y copiar mi clave secreta Error de autenticación El sistema biométrico no pudo autenticar al propietario de este teléfono El sistema biométrico no pudo autenticar al propietario de este teléfono. Error: %1$s @@ -346,7 +360,7 @@ Te notifica cuando alguien te zapea %1$s sats De %1$s - para %1$s + por %1$s Notificar: Unirse a la conversación ID de usuario o grupo @@ -367,7 +381,7 @@ Zapraiser Agrega una meta de sats para recaudar por esta publicación. Los clientes compatibles pueden mostrarla como una barra de progreso para incentivar las donaciones. Cantidad objetivo en sats - Zapraiser a %1$s. %2$s sats hasta la meta + Zapraiser en %1$s. %2$s sats hasta la meta Relé de lectura Relé de escritura Se produjo un error al intentar obtener información sobre el relé de %1$s @@ -429,7 +443,7 @@ Idioma Tema Vista previa de imagen - Reproducción de video + Reproducción de vídeo Vista previa de URL Desplazamiento inmersivo Ocultar barras de navegación al desplazarse @@ -453,6 +467,7 @@ La activación de este modo requiere que Amethyst envíe un mensaje NIP-24 (mensajes directos sellados, de grupo y “GiftWrapped\"). NIP-24 es nuevo y la mayoría de los clientes aún no lo han implementado. Comprueba que el destinatario use un cliente compatible. Activar Público + Nuevo grupo público o privado Privado Para Asunto @@ -469,6 +484,7 @@ Mostrar vistas previas de URL Cuándo cargar imágenes Copiar al portapapeles + Copiar npub al portapapeles Copiar URL al portapapeles Copiar ID de la nota al portapapeles Creado @@ -524,7 +540,7 @@ Los tokens de Cashu ya se gastaron. Cashu recibido Se enviaron %1$s sats a tu billetera (comisión: %2$s sats). - No se encontró ninguna billetera Cashu compatible en el sistema + No se encontró ninguna billetera de Cashu compatible en el sistema No se puede obtener la factura de los servidores del destinatario El proveedor de conexión de billetera devolvió el siguiente error: %1$s No se pudo conectar a Tor @@ -532,6 +548,7 @@ No se pudo ensamblar la LNUrl desde la dirección de Lightning \"%1$s\". Comprueba la configuración del usuario. El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning \"%2$s\". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta. No se pudo resolver %1$s. Comprueba si tienes conexión, si el servidor está activo y si la dirección de Lightning \"%2$s\" es correcta. + No se pudo resolver %1$s. Comprueba si tienes conexión, si el servidor está activo y si la dirección de Lightning \"%2$s\" es correcta..\n\nLa excepción fue: %3$s No se pudo obtener la factura de %1$s Error al analizar JSON desde la dirección de Lightning. Comprueba la configuración de Lightning del usuario. No se encontró la URL de devolución de llamada en la configuración del servidor de la dirección de Lightning del usuario. @@ -596,4 +613,61 @@ El servidor no proporcionó una URL después de la subida No se pudo descargar el contenido subido desde el servidor No se pudo preparar el archivo local para subir: %1$s + Iniciar sesión con código QR + Ruta + Inicio + Buscar + Descubrir + Mensajes + Notificaciones + Global + Cortos + Filtros de seguridad + Nueva publicación + Nuevos cortos: imágenes o videos + Nueva nota comunitaria + Abrir todas las reacciones a esta publicación + Cerrar todas las reacciones a esta publicación + Responder + Impulsar o citar + Me gusta + Zap + Imagen de perfil de %1$s + Relé %1$s + Ampliar lista de relés + Opciones de notas + Selector de lista de relés + Encuesta + Desactivar encuesta + Factura de Bitcoin + Cancelar factura de Bitcoin + Cancelar la venta de un artículo + Zapraiser + Cancelar zapraiser + Ubicación + Eliminar ubicación + Divisiones de zaps + Cancelar división de zap + Agregar advertencia de contenido + Eliminar advertencia de contenido + Mostrar npub como código QR + Dirección inválida + Amethyst recibió un URI para abrir pero era inválido: %1$s + ¡Zapea a los desarrolladores! + Tu donación nos ayuda a marcar la diferencia. ¡Cada sat cuenta! + Donar ahora + fue posible gracias a: + Esta versión fue posible gracias a: + Versión %1$s + ¡Gracias! + Límite máximo + Escrituras restringidas + Bifurcación de + BIFURCACIÓN + Repositorio Git: %1$s + Web: + Clon: + OTS: %1$s + Prueba de marca de tiempo + Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index da4d9d1c3..ac0627933 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -90,6 +90,8 @@ افزودن رله نمایش نام نام من + استریچ باحالیان + خوش آمدی استریچ! نام کاربری نام کاربری من درباره من @@ -143,7 +145,14 @@ شرایط استفاده پذیرش شرایط الازمیست کلید الزامیست + نام الزامی است ورود + ثبت نام + ایجاد حساب کاربری + شما را چی صدا بزنیم؟ + حساب کاربری ناستر ندارید؟ + از قبل حساب کاربری ناستر دارید؟ + ایجاد حساب کاربری جدید ساختن جفت کلید جدید در حال بارگیری خبرنامه بارگذاری حساب کاربری @@ -185,12 +194,6 @@ همه جدیدها را خوانده شده کن همه را خوانده شده کن بازیابی کلیدها - ## راهنمای ایمنی و بازیابی کلید - \n\n- حساب کاربری شما با یک کلید خصوصی ایمن می شود. این کلید یک رشته تصادفی اعداد و ارقام است که با **nsec1** آغاز می گردد. - \n\n- هر کس به کلید خصوصی شما دست یابد می تواند با هویت شما اقدام به انتشار محتوا نماید. کلید خصوصی خود را در هیچ نرم افزار یا وب سایتی که به آن اعتماد ندارید قرار ندهید. - \n- سازندگان آماتیست **هرگز** از شما کلید خصوصی تان را نمی خواهند. - \n- حتما یک کپی از کلید خصوصی را برای بازیابی حساب کاربری نزد خود نگه دارد. ما استفاده از یک نرم افزار مدیریت پسورد را توصیه می نماییم. - کلید خصوصی در کلیپبورد کپی شد کلید خصوصیم را کپی کن احراز هویت انجام نشد @@ -318,6 +321,7 @@ لیست دنبال ها همه دنبال ها همگانی + لیست خموش ## از طریق Tor با Orbot متصل شوید \n\n1. نصب کنید [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) \n2. شروع Orbot @@ -385,6 +389,9 @@ پرداخت توکن Cashu بازپرداخت + ارسال به کیف پول زپ + باز کردن در کیف پول Cashu + کپی کردن توکن آدرس لایتنینگ تنظیم نشده است توکن به کلیپبورد کپی شد زنده @@ -396,6 +403,7 @@ خروج همه اطلاعات محلی شما را پاک می کند. مطمئن شوید که کلید خصوصی خود را بکاپ گرفته و ذخیره کرده اید تا حساب کاربری تان را از دست ندهید. می خواهید ادامه دهید؟ برچسب های دنبال شده رله ها + بازار زنده انجمن گپ @@ -454,6 +462,7 @@ پخش خودکار ویدئوها و جیف ها نشان دادن پیش نمایش URL هنگام بارگیری تصاویر + کپی به کلیپ‌بورد کپی URL به کلیپبورد کپی شناسه یادداشت به کلیپبورد ایجاد شده در @@ -489,6 +498,9 @@ پرداخت شد کیف پول %1$s خطا در بازکردن اپلیکیشن امضا کننده + اپ امضا کننده یافت نشد. بررسی کنید که اپ حذف نشده باشد. + اپ امضا کننده رد کرد + مطمئن شوید که اپ امضا کننده این تراکنش را تایید کرده است هیچ کیف پولی برای پرداخت صورتحساب لایتنینگ یافت نشد (خطا: %1$s). لطفا کیف پول لایتنینگی برای استفاده از زپ نصب کنید هیچ کیف پولی برای پرداخت صورتحساب لایتنینگ یافت نشد. لطفا یک کیف پول لایتنینگی برای استفاده از زپ نصب کنید کلمات پنهان @@ -506,6 +518,7 @@ توکن Cashu خرج شده است. Cashu دریافت شد %1$s ساتوشی به کیف پول شما ارسال شد. (کارمزد: %2$s ساتوشی) + هیچ کیف پول Cashu سازگاری در سیستم یافت نشد صورتحساب از سرور گیرنده دریافت نشد ارائه دهنده اتصال کیف پول شما خطای روبرو را داد: %1$s نمی توان به Tor وصل شد @@ -513,6 +526,7 @@ LNUrl از آدرس لایتنینگ \"%1$s\" بدست نیامد. تنظیمات کاربر را بررسی کنید. خدمات لایتنینگ %1$s گیرنده دردسترس نیست. از آدرس لایتنینگ \"%2$s\" محاسبه شد. خطا: %3$s. بررسی کنید آیا سرور کار می کند و آیا آدرس لایتنینگ صحیح است. مشکل %1$s حل نشد. بررسی کنید که به اینترنت متصل باشید، سرور کار کند و آدرس لایتنینگ %2$s صحیح باشد. + مشکل %1$s حل نشد. بررسی کنید که به اینترنت متصل باشید، سرور کار کند و آدرس لایتنینگ %2$s صحیح باشد. \n\nخطای %3$s صورتحساب از %1$s گرفته نشد خطا در تفسیر JSON از آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید. URL بازخوانی در پیکربندی سرور آدرس لایتنینگ یافت نشد @@ -532,4 +546,49 @@ برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Nfty](https://ntfy.sh/). پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. + پیغام از طرف %1$s + رسته + به فروشنده پیغام دهید + سلام %1$s، هنوز موجوده؟ + سلام، هنوز موجوده؟ + چیزی بفروشید + عنوان + آیفون 13 + شرایط + دسته بندی + قیمت (به ساتوشی) + 1000 + موقعیت مکانی + شهر، استان، کشور + جدید + این مورد نو هست، در بسته بندی اصلی + در حد نو + دست دوم است، اما ظاهرش تمیز است + خوب + کمی در ظاهر نشان می دهد که دست دوم است + منصفانه + هنوز مناسب است و کار می کند + پوشاک + لوازم جانبی + الکترونیکی + مبلمان + یادگاری + کتاب + حیوانات خانگی + ورزش + تناسب اندام + هنر + دست سازه + خانه + اداره + خوراکی + گوناگون + سایر + رسانه بارگذاری نشد + فایل فشرده باز نشد + خطا در فشرده سازی رسانه: %1$s + خطای بارگیری: %1$s + سرور پس از بارگذاری URL نداد + فایل بارگذاری شده از سرور بارگیری نشد + فایل محلی برای بارگذاری آماده نشد: %1$s diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 4f0a28b37..6b403d633 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -185,12 +185,6 @@ Merkitse kaikki Uudet luetuiksi Merkitse kaikki luetuiksi Varmuuskopioi Avaimet - ## Avaimen varmuuskopiointi ja turvallisuusvinkit - \n\nTiliäsi suojaa salainen avain. Avain on pitkä satunnainen merkkijono, joka alkaa **nsec1**. Kaikki, joilla on pääsy salaiseen avaimeesi, voivat julkaista sisältöä käyttäen identiteettiäsi. - \n\n- Älä **koskaan** laita salaista avaintasi mihinkään verkkosivustoon tai ohjelmistoon, johon et luota. - \n- Amethyst-kehittäjät eivät **koskaan** kysy sinulta salaista avaintasi. - \n- **Säilytä** salaisen avaimen varmuuskopio turvallisesti tilin palauttamista varten. Suosittelemme salasanahallintaohjelman käyttämistä. - Salainen avain (nsec) kopioitu leikepöydälle Kopioi salainen avain Tunnistautuminen epäonnistui diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 62cc6b369..266c0422d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -3,6 +3,7 @@ Pointer vers le QR code Montrer le QR code Image de profil + Votre Photo de Profil Scanner le QR code Montrer quand même Post signalé inapproprié par @@ -15,6 +16,7 @@ Spam Falsification d\'identité Comportement illégal + Autre inconnu Icône de relais Auteur inconnu @@ -22,6 +24,9 @@ Copier la clé publique de l\'utilisateur Copier l\'ID de la note Diffuser + Horodater + Horodatage : Confirmations en attente + OTS : En attente Demande de suppression Bloquer / Signaler @@ -43,6 +48,7 @@ Boost boosté Citation + Fork Nouveau montant en Sats Ajouter "répondre à " @@ -90,6 +96,8 @@ Ajouter un relais Nom visible du public Mon nom visible du public + Ostrich McGénial + Bienvenue Ostrich! Nom d\'utilisateur Mon nom d\'utilisateur Biographie @@ -136,12 +144,15 @@ Nettoyer Logo de l\'App nsec / npub / hex private key + mot de passe pour ouvrir la clé Révéler le mot de passe Cacher le mot de passe Clé non valide + Clé invalide : %1$s "J'accepte les " conditions d\'utilisation L\'acceptation des conditions est requise + Mot de passe requis La clé est requise Un nom est requis Connexion @@ -192,14 +203,19 @@ Marquer tous les Nouveaux comme lu Tout marquer comme lu Clés de sauvegarde - ## Sauvegarde des clés et conseils de sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue chaîne aléatoire commençant par **nsec1**. Toute personne ayant accès à votre clé secrète peut publier du contenu en utilisant votre identité. - \n\n- Ne communiquez **jamais** votre clé secrète à un site Web ou un logiciel auquel vous ne faites pas confiance. + ## Sauvegarde des Clés et Conseils de Sécurité + \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. + \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d\'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Conservez** une sauvegarde sécurisée de votre clé secrète pour la récupération de compte. Nous vous recommandons d\'utiliser un gestionnaire de mots de passe. + \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d\'utiliser un gestionnaire de mots de passe. + Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. + \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. + + Échec du chiffrement de votre clé privée Clé secrète (nsec) copiée dans le presse-papiers Copier ma clé secrète + Chiffrer et copier ma clé secrète Échec de l\'authentification La biométrie n\'a pas pu authentifier le propriétaire de ce téléphone La biométrie n\'a pas pu authentifier le propriétaire de ce téléphone. Erreur : %1$s @@ -393,6 +409,8 @@ Paiement Jeton Cashu Échanger + Envoyer vers le portefeuille Zap + Ouvrir dans le portefeuille Cashu Copier le Jeton Adresse Lightning non définie Jeton copié dans le presse-papiers @@ -449,6 +467,7 @@ Pour activer ce mode, Amethyst doit envoyer un message NIP-24 (GiftWrapped, Sealed Direct et Group Messages). Le protocole NIP-24 est nouveau et la plupart des clients ne l\'ont pas encore mis en oeuvre. Assurez-vous que le destinataire utilise un client compatible. Activer Public + Nouveau Groupe Public ou Privé Privé À Sujet @@ -465,6 +484,7 @@ Afficher la prévisualisation d\'URL Quand charger les images Copier dans le presse-papiers + Copier npub dans le presse-papiers Copier l\'URL dans le presse-papiers Copier l\'ID de la note dans le presse-papiers Créé le @@ -513,14 +533,22 @@ Impossible de payer la facture Impossible de retirer Impossible de configurer Wallet Connect + Erreur lors de l\'analyse de l\'événement de connexion NIP-47. Vérifiez si tout est correct avec le prestataire de votre Portefeuille : %1$s. Erreur : %2$s + Erreur lors de l\'analyse de l\'événement de connexion NIP-47. Vérifiez si tout est correct avec le prestataire de votre Portefeuille : %1$s. + Impossible d\'utiliser ce Cashu + Le Mint a fourni le message d\'erreur suivant : %1$s + Les jetons de cashu sont déjà dépensés. Cashu reçu %1$s sats ont été envoyés à votre portefeuille. (Frais : %2$s sats) + Aucun portefeuille Cashu compatible trouvé dans le système Impossible de récupérer la facture depuis les serveurs du destinataire + Le fournisseur de connexion de votre portefeuille a retourné l\'erreur suivante : %1$s Impossible de se connecter à Tor Téléchargement du document relais indisponible Impossible d\'assembler LNUrl à partir de l\'adresse Lightning \"%1$s\". Vérifiez la configuration de l\'utilisateur Le service lightning du récepteur à %1$s n\'est pas disponible. Il a été calculé à partir de l\'adresse lightning \"%2$s\". Erreur: %3$s. Vérifiez si le serveur est en ligne et si l\'adresse lightning est correcte Impossible de résoudre %1$s. Vérifiez si vous êtes connecté, si le serveur est en ligne et si l\'adresse lightning %2$s est correcte + Impossible de résoudre %1$s. Vérifiez si vous êtes connecté, si le serveur est actif et si l\'adresse Lightning %2$s est correcte.\n\nL\'erreur était : %3$s Impossible de récupérer la facture depuis %1$s Erreur lors de l\'analyse du JSON à partir de l\'Adresse Lightning. Vérifiez la configuration lightning de l\'utilisateur URL de callback introuvable dans la configuration serveur de l\'adresse lightning de l\'utilisateur @@ -585,4 +613,61 @@ Le serveur n\'a pas fourni d\'URL après le téléversement Impossible de télécharger le média depuis le serveur Impossible de préparer le fichier local à téléverser: %1$s + Se connecter avec un QR Code + Route + Accueil + Rechercher + Découvrir + Messages + Notifications + Général + Shorts + Filtres de Sécurité + Nouveau Message + Nouveaux Shorts : images ou vidéos + Nouvelle Note Communautaire + Ouvrir toutes les réactions à ce message + Fermer toutes les réactions à ce message + Répondre + Booster ou Citer + J\'aime + Zap + Photo de Profil de %1$s + Relais %1$s + Développer la liste des relais + Options de la note + Sélecteur de liste de relais + Sondage + Désactiver le Sondage + Facture Bitcoin + Annuler la Facture Bitcoin + Annuler la Vente d\'un Objet + Collecte de Zaps + Annuler la Collecte de Zaps + Emplacement + Retirer l\'Emplacement + Partage des Zaps + Annuler le partage des Zaps + Ajouter un avertissement de contenu + Retirer l\'avertissement de contenu + Afficher npub en tant que QR code + Adresse invalide + Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s + Zap les Devs ! + Votre don nous aide à faire la différence. Chaque sat compte ! + Faire un don maintenant + vous a été apporté par : + Cette version vous a été apportée par : + Version %1$s + Merci ! + Limite maximale + Écritures limitées + Forké depuis + FORK + Dépôt Git : %1$s + Web : + Clone : + OTS: %1$s + Preuve d\'horodatage + Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index f1d550af2..d4715f21a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -3,6 +3,7 @@ Mutass a QR kódra QR kód megjelenítése Profil kép + Profilkép QR kód beolvasása Mutasd A bejegyzést nem megfelelőként jelölte meg @@ -138,12 +139,15 @@ Törlés App Logó nsec / npub / hex privát kulcs + jelszót a kulcs kinyitásához Jelszó megjelenítése Jelszó elrejtése Érvénytelen kulcs + Érvénytelen kulcs: %1$s "Elfogadom a " használati feltételeket A feltételek elfogadása szükséges + Jelszó megadása kötelező Kulcs szükséges Név megadása kötelező Bejelentkezés @@ -194,14 +198,18 @@ Az összes új megjelölése olvasottként Összes megjelölése olvasottként Biztonsági Kulcsok - ## Kulcs- és biztonsági mentési tippek - \n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. - \n\n- **Ne** helyezze el titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. + ## Kulcs- és biztonsági mentési tippek + \n\nFiókodat titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki a Te titkos kulcsodhoz hozzáfér, az a személyazonosságod használatával bármilyen tartalmat közzétehet. + \n\n- **Ne** helyezd el titkos kulcsodat olyan webhelyen vagy szoftverben, amelyben nem bízol. \n- Az Amethyst fejlesztők a titkos kulcsodat **soha** nem fogják elkérni. - \n- A fiók-helyreállításhoz, titkos kulcsáról **mindig** készítsen biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát. + \n- A fiók-helyreállításhoz, titkos kulcsodról **mindig** készíts biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát. + A nagyobb biztonság érdekében a kulcsot jelszóval titkosíthatod. Ez a kulcs **ncryptsec1**-val kezdődik, és a jelszavad nélkül nem használható. + \n\nHa elveszíted a jelszavát, nem tudod a kulcsodat visszaállítani. + Nem sikerült a privát kulcsot titkosítani A titkos kulcs (nsec) a vágólapra másolva A titkos kulcsom másolása + Titkosítsd és másold a titkos kulcsomat Hitelesítés nem sikerült A telefon tulajdonosát a biometrikus adatokkal nem sikerült hitelesíteni A telefon tulajdonosát a biometrikus adatokkal nem sikerült hitelesíteni. Hiba: %1$s @@ -453,6 +461,7 @@ Az Amethystnek ennek a módnak az aktiválásához NIP-24 üzenetet kell küldenie (GiftWrapped, Zárolt direkt és csoportos üzeneteket). A NIP-24 új, és a legtöbb kliens még nem implementálta. Győződj meg arról, hogy a fogadó fél kompatibilis klienst használ. Aktiválás Publikus + Új nyilvános vagy privát csoport Privát Címzett Téma @@ -469,6 +478,7 @@ URL előnézetek megjelenítése Mikor kell a képeket betölteni Másolás a vágólapra + Npub másolása a vágólapra Az URL vágólapra másolása A bejegyzésazonosító vágólapra másolása Létrehozva @@ -532,6 +542,7 @@ Nem sikerült az LNUrl-t a(z) \"%1$s\" Lightning-címről összeállítani. Ellenőrizd a felhasználó beállítását A fogadó lightning szolgáltatása itt: %1$s nem érhető el. A \"%2$s\" lightning címből lett kiszámítva. Hiba: %3$s. Ellenőrizd, hogy a szerver működik-e, és hogy a lightning cím helyes-e Nem sikerült megoldani: %1$s. Ellenőrizd, hogy csatlakozol-e, működik-e a szerver, és hogy a %2$s lightning cím helyes-e + Nem sikerült megoldani: %1$s. Ellenőrizd, hogy csatlakozol-e, működik-e a szerver, és hogy a %2$s lightning cím helyes-e.\n\nKivétel: %3$s Nem sikerült a számlát a következőtől: %1$s lekérni Hiba a Lightning-címből származó JSON elemzésekor. Ellenőrizd a felhasználó Lightning beállítását A visszahívási URL a felhasználó Lightning címszerver konfigurációjában nem található @@ -596,4 +607,44 @@ A szerver a feltöltés után nem adott URL-t Nem sikerült a szerverről a feltöltött médiát letölteni Nem sikerült előkészíteni a helyi fájlt a feltöltésre: %1$s + Bejelentkezés QR kóddal + Útvonal + Főoldal + Keresés + Felfedezés + Üzenetek + Értesítések + Globális + Rövidfilmek + Biztonsági szűrők + Új bejegyzés + Új rövidfilmek: képek vagy videók + Új közösségi bejegyzés + Nyissa meg a bejegyzésre adott összes reakciót + Zárja be a bejegyzésre adott összes reakciót + Válasz + Megosztás vagy Idézés + Like + Zap + %1$s profilképe + %1$s csomópont + Csomópont lista kibontása + Bejegyzés opciók + Csomópont lista választó + Szavazás + Szavazás kikapcsolása + Bitcoin számla + Bitcoin számla visszavonása + Egy tétel eladásának visszavonása + ZapGyűjtés + ZapGyűjtés visszavonása + Pozíció + Pozíció törlése + Zap megosztások + Zap megosztások visszavonása + Tartalomra vonatkozó figyelmeztetés + Tartalomra vonatkozó figyelmeztetés visszavonása + Az npub megjelenítése QR-kódként + Érvénytelen cím + Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s diff --git a/app/src/main/res/values-in-rID/strings.xml b/app/src/main/res/values-in-rID/strings.xml index 8fb6e9540..70e72e198 100644 --- a/app/src/main/res/values-in-rID/strings.xml +++ b/app/src/main/res/values-in-rID/strings.xml @@ -185,12 +185,6 @@ Tandai semua yang baru sebagai telah dibaca Tandai semua sebagai telah dibaca Kunci Cadangan - ## Kiat Pencadangan dan Keamanan Kunci - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini berupa string acak panjang yang dimulai dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia Anda dapat mempublikasikan konten menggunakan identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau aplikasi apa pun yang tidak Anda percayai. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Simpanlah** cadangan kunci rahasia Anda dengan aman untuk pemulihan akun. Kami sarankan menggunakan aplikasi pengelola kata sandi. - Kunci rahasia (nsec) telah disalin ke papan klip Salin kunci rahasia saya Autentikasi gagal diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index b4e2a9a18..13ee2612a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -182,7 +182,6 @@ Tandai pesan baru telah dibaca Tandai semua telah dibaca Cadangkan Kunci - "\n ## Tip Pencadangan dan Keamanan Utama\n \n\nAkun Anda diamankan dengan kunci rahasia. Kuncinya adalah string acak panjang yang dimulai dengan **nsec1**.. Siapa pun yang memiliki akses ke kunci rahasia Anda dapat mempublikasikan konten menggunakan identitas Anda.\n \n\n- **Jangan** letakkan kunci rahasia Anda di situs web atau perangkat lunak apa pun yang tidak Anda percayai.\n \n- Pengembang Aplikasi Amethyst **tidak akan pernah** meminta kunci rahasia Anda.\n \n- **Pastikan** simpan cadangan aman kunci rahasia Anda untuk pemulihan akun. Kami merekomendasikan menggunakan aplikasi khusus pengelola kata sandi.\n " Kunci rahasia (nsec) disalin ke clipboard Salin kunci rahasia saya Autentikasi gagal diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 35cbbb8a7..abf7126ae 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -182,12 +182,6 @@ Segna tutti i nuovi come letti Segna tutti come letti Esegui backup delle chiavi - ## Backup delle Chiavi e Consigli di Sicurezza - \n\nIl tuo account è reso sicuro da una chiave privata. La chiave è una lunga stringa di caratteri casuali che inizia con **nsec1**. Chiunque abbia accesso alla tua chiave privata puó pubblicare contenuti con la tua identitá. - \n\n- Non devi **mai** inserire la tua chiave privata in un sito o software di cui non ti fidi. - \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave privata. - \n- Mantieni **sempre** al sicuro un backup della tua chiave privata per recuperare l\'account. Ti suggeriamo di usare un password manager. - Chiave segreta (nsec) copiata negli appunti Copia la mia chiave segreta Autenticazione fallita diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 2aff0899b..ed0a8fe01 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -177,12 +177,6 @@ すべての「新規リクエスト」を既読にする すべて既読にする 鍵をバックアップ - ## 鍵のバックアップと安全に利用するためのTips - \n\nあなたのアカウントは秘密鍵によって保護されています。秘密鍵は **nsec1** から始まる長いランダムな文字列です。秘密鍵にアクセスできる人は、あなたのアカウントを使用してコンテンツを公開することができます。 - \n\n- 信頼できないウェブサイトやソフトウェアには、秘密鍵を **渡さない** でください。 - \n- Amethystの開発者があなたに秘密鍵をお聞きすることは **ありません** 。 - \n- アカウント復旧のために、秘密鍵を安全な方法で **バックアップして** ください 。パスワードマネージャーの利用を推奨しています。 - 秘密鍵 (nsec) をクリップボードにコピーしました 秘密鍵をコピー 認証に失敗しました diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 9b79a7b50..ea7fb69b2 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -3,6 +3,7 @@ Richt camera op de QR-code Toon QR Profielfoto + Jouw profielfoto Scan QR Laat toch zien Bericht gemarkeerd als ongepast door @@ -194,12 +195,6 @@ Nieuwe markeren als gelezen Alles markeren als gelezen Back-up sleutels - ## Sleutel back-up en veiligheidstips - \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. - \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. - \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. - \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken. - Privésleutel (nsec) gekopieerd naar klembord Kopieer mijn privésleutel Authenticatie mislukt @@ -453,6 +448,7 @@ Voor het activeren van deze modus is Amethyst nodig om een NIP-24 bericht te versturen. NIP-24 is nieuw en de meeste clients hebben deze nog niet geïmplementeerd. Zorg ervoor dat de ontvanger een compatibele client gebruikt. Activeren Publiek + Nieuwe openbare of besloten groep Privé Aan Onderwerp @@ -469,6 +465,7 @@ Toon URL-voorbeelden Wanneer afbeeldingen te laden Kopiëren naar klembord + Kopieer npub naar klembord Kopieer URL naar klembord Note naar klembord kopiëren Gemaakt op @@ -532,6 +529,7 @@ Kon LNUrl niet samenbrengen van Lightning Adress \"%1$s\". Controleer de instellingen van de gebruiker De Lightning service van de ontvanger bij %1$s is niet beschikbaar. Het werd berekend op basis van het Lightning Adress \"%2$s\". Fout: %3$s. Controleer of de server klaar is en of het Lightning Adress juist is Kan %1$s niet oplossen. Controleer of u verbonden bent, of de server online is en of het Lightning Adress %2$s juist is + Kon %1$s niet oplossen. Controleer of u verbonden bent, of de server online is en of het Lightning Adress %2$s juist is. \n\nUitzondering was: %3$s Kan invoice niet ophalen van %1$s Fout bij het verwerken van JSON uit Lightning Adress. Controleer de Lightning setup van de gebruiker Callback URL niet gevonden in de Lightning Adress configuratie van de gebruiker @@ -596,4 +594,44 @@ Server heeft geen URL opgegeven na uploaden Kan de geüploade media niet downloaden van de server Kon lokaal bestand niet voorbereiden voor upload: %1$s + Inloggen met QR-Code + Route + Beginscherm + Zoeken + Ontdekken + Berichten + Notificaties + Globaal + Shorts + Beveiligingsfilters + Nieuw bericht + Nieuwe shorts: afbeeldingen of video\'s + Nieuwe community note + Open alle reacties op dit bericht + Sluit alle reacties op dit bericht + Antwoorden + Boost of citaat + Vind ik leuk + Zap + Profielfoto van %1$s + Relay %1$s + Lijst van relays uitvouwen + Note opties + Relay selectiemenu + Poll + Poll uitschakelen + Bitcoin invoice + Annuleer Bitcoin invoice + Verkoop van een item annuleren + Zapraiser + Annuleer Zapraiser + Locatie + Locatie verwijderen + Zap splits + Annuleer splitsing Zap + Content waarschuwing toevoegen + Content waarschuwing verwijderen + Toon npub als een QR-code + Ongeldig adres + Amethist ontving een URI om te openen, maar die uri was ongeldig: %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ca944e22b..d62aaa9e2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -3,7 +3,8 @@ Aponte para o código QR Mostrar QR Imagem de perfil - Escanear QR + Sua Foto de Perfil + Ler QR Mostrar de qualquer maneira A postagem foi sinalizada como imprópria por postagem não encontrada @@ -43,6 +44,7 @@ Impulsionar impulsionado Citar + Garfo Novo Valor em Sats Adicionar "respondendo para " @@ -69,7 +71,7 @@ Novo Canal Nome do Canal Meu grupo - Url da foto + URL da foto Descrição "Sobre nós.. " O que você está pensando? @@ -103,7 +105,7 @@ Imagem salva para a galeria Falha ao salvar a imagem Enviar Imagem - Enviando... + Enviando… Usuário não tem um endereço lightning configurado para receber sats "responda aqui.. " Copia o ID do canal (note) para compartilhar @@ -138,12 +140,15 @@ Limpar Logo do aplicativo nsec / npub / hex chave privada + senha para abrir a chave Mostrar senha Esconder senha Chave inválida + Chave inválida: %1$s "Eu aceito os " termos de uso É necessário aceitar os termos de uso + Senha é necessária Chave é obrigatória Um nome é necessário Entrar @@ -194,14 +199,17 @@ Marcar todas novas solicitações como lidas Marcar todas como lidas Copia de segurança das chaves - ## Backup de chaves e dicas de segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma string aleatória longa começando com **nsec1**. Qualquer pessoa que tenha acesso à sua chave secreta pode publicar conteúdo usando sua identidade. - \n\n- **Não** coloque sua chave secreta em qualquer site ou software em que não confie. - \n- Os desenvolvedores do Amethyst **nunca** pedirão sua chave secreta. - \n- **Faça** um backup seguro de sua chave secreta para recuperação de conta. Recomendamos o uso de um gerenciador de senhas. - + ## Dicas de Backup de Chave e Segurança + \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. + \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. + \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. + \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. + Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. + \n\nSe você perder sua senha, não será possível recuperar sua chave. + Falha ao criptografar sua chave privada Chave secreta (nsec) copiada Copiar minha chave secreta + Criptografar e copiar minha chave secreta Autenticação falhou A autenticação biométrica falhou ao autenticar o proprietário deste telefone A autenticação biométrica falhou ao autenticar o proprietário deste telefone. Erro: %1$s @@ -274,7 +282,7 @@ Postar enquete Campos obrigatórios: Destinatários Zap - Descrição da enquete principal... + Descrição da enquete principal… Opção %s Descrição da opção de enquete Campos opcionais: @@ -321,7 +329,7 @@ Configuração do Tor/Orbot Conecte-se através da configuração do Orbot Desconectar do Orbot/Tor? - Seus dados serão transferidos imediatamente na rede regular + Seus dados serão transferidos imediatamente na rede aberta Sim Não Lista de seguidores @@ -447,12 +455,13 @@ Encaminhar Zaps para: Os clientes que suportam encaminharão zaps para o endereço lightning ou perfil de usuário abaixo, em vez do seu Expor localização como - Adiciona um Geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual + Adicione um geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador Novo recurso Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-24 (GiftWrapped, Sealed Direct and Group Messages). NIP-24 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível. Ativar Público + Novo Grupo Público ou Privado Privado Para Assunto @@ -463,18 +472,19 @@ Mudando o nome dos novos objetivos. Colar da área de transferência Para a interface do aplicativo - Tema Escuro, Claro ou Sistema + Tema Escuro, Claro ou Padrão Carregar automaticamente imagens e GIFs Reproduz automaticamente vídeos e GIFs Mostrar visualizações de URL Quando carregar imagens Copiar para a Área de Transferência + Copiar npub para a área de transferência Copiar URL para a área de transferência Copiar ID da nota para a área de transferência Criado em Regras Login com Amber - Atualize seu status + O que você está fazendo? Erro ao analisar mensagem de erro Os votos são ponderados pelo valor do zap. Você pode definir um valor mínimo para evitar spammers e um valor máximo para evitar que grandes zappers assumam o controle da enquete. Use o mesmo valor em ambos os campos para garantir que cada voto tenha o mesmo valor. Deixe em branco para aceitar qualquer valor. Não foi possível enviar o Zap @@ -532,6 +542,7 @@ Não foi possível montar o LNUrl a partir do Endereço Lightning \"%1$s\". Verifique a configuração do usuário O serviço Lightning do destinatário em %1$s não está disponível. Foi calculado a partir do endereço Lightning \"%2$s\". Erro: %3$s. Verifique se o servidor está funcionando e se o endereço Lightning está correto Não foi possível resolver %1$s. Verifique se você está conectado, se o servidor está funcionando e se o endereço Lightning %2$s está correto + Não foi possível resolver %1$s. Verifique se você está conectado, se o servidor está funcionando e se o endereço Lightning %2$s está correto.\n\nExceção foi: %3$s Não foi possível obter a fatura de %1$s Erro ao analisar JSON do Endereço Lightning. Verifique a configuração de Lightning do usuário URL de retorno não encontrada na configuração do servidor de endereço Lightning do usuário @@ -596,4 +607,58 @@ O servidor não forneceu uma URL após o upload Não foi possível baixar o arquivo de mídia carregado do servidor Não foi possível preparar o arquivo local para enviar: %1$s + Entrar com Código QR + Rota + Início + Buscar + Descobrir + Mensagens + Notificações + Global + Vídeos Curtos + Filtros de Segurança + Novo Post + Novos Vídeos Curtos: imagens ou vídeos + Nova Nota da Comunidade + Abrir todas as reações a esta postagem + Fechar todas as reações a esta postagem + Responder + Impulsionar ou Citar + Gostar + Zap + Foto de perfil de %1$s + Repassar %1$s + Expandir lista de repasse + Opções de nota + Seletor de lista de repasse + Enquete + Desativar enquete + Fatura de Bitcoin + Cancelar Fatura de Bitcoin + Cancelar Venda de Item + Avaliador Zap + Cancelar Avaliador Zap + Localização + Remover Localização + Divisão Zap + Cancelar Divisão Zap + Adicionar aviso de conteúdo + Remover aviso de conteúdo + Mostrar npub como um código QR + Endereço inválido + O Amethyst recebeu um URI para abrir, mas esse URI era inválido: %1$s + Zap os desenvolvedores! + Sua doação nos ajuda a fazer a diferença. Cada sat conta! + Doar agora + foi trazido a você por: + Esta versão foi trazida a você por: + Versão %1$s + Obrigado! + Limite máximo + Gravações Restritas + Forkado de + FORK + Repositório Git: %1$s + Site: + Clonar: diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d18eee40f..bdc8e3ab0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -183,12 +183,6 @@ Отметить все новые прочитанными Отметить всё прочитанным Резервное копирование - ## Резервное копирование и советы по безопасности - \n\nВаш аккаунт защищен приватным ключом. Этот ключ – длинная случайная строка с приставкой **nsec1**. Любой, кто владеет вашим приватным ключом, может писать от вашего имени. - \n\n- **Никогда** не вставляйте ваш приватный ключ на сайтах или в приложениях, которым вы не доверяете. - \n- Разработчики Amethyst **никогда** не спросят ваш приватный ключ. - \n- **Сохраните** резервную копию приватного ключа, чтобы иметь возможность восстановить аккаунт. Советуем использовать менеджер паролей. - Приватный ключ (nsec) скопирован Скопировать мой приватный ключ Ошибка входа diff --git a/app/src/main/res/values-sr-rSP/strings.xml b/app/src/main/res/values-sr-rSP/strings.xml index 0264665e9..25197aa21 100644 --- a/app/src/main/res/values-sr-rSP/strings.xml +++ b/app/src/main/res/values-sr-rSP/strings.xml @@ -11,4 +11,26 @@ Референтни догађај није пронађен Није могуће дешифровати поруку Групна слика + Експлицитни садржај + Спам + лажно представљање + Незаконито понашање + Непознат + Икона релеја + Непознати аутор + Копирај текст + Копирај ИД аутора + Копирај ИД белешке + Емитовања + Захтевај брисање + Блокирај / Пријави + + Пријави нежељену пошту/превару + Пријавите лажно представљање + Пријавите експлицитни садржај + Пријавите незаконито понашање + Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте могли да одговорите + Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте могли да појачате постове + Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове + Нема подешавања износа Зап. Дуго притисните да бисте променили diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index 246a9ab38..e099d4ffb 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -3,6 +3,7 @@ Peka mot QR-koden Visa QR Profilbild + Din profilbild Scanna QR Visa ändå Inlägget flaggades som olämpligt av @@ -43,6 +44,7 @@ Boosta boostad Citera + Förgrening Nytt belopp i Sats Lägg till "Svarar till " @@ -138,12 +140,15 @@ Rensa App Logo nsec / npub / hex privat nyckel + lösenord för att öppna nyckeln Visa lösenord Göm lösenord Ogiltig nyckel + Ogiltig nyckel: %1$s "Jag accepterar " villkor Godkännande av villkor krävs + Lösenord krävs Nyckel krävs Namn är obligatoriskt Inloggning @@ -194,13 +199,17 @@ Markera alla nya som lästa Markera allt som läst Backup-nycklar - ## Nyckelsäkerhetskopiering och säkerhetstips - \n\nDitt konto är skyddat av en hemlig nyckel. Nyckeln är en lång slumpmässig sträng som börjar med **nsec1**. Alla som har tillgång till din hemliga nyckel kan publicera innehåll med din identitet. - \n\n- Lägg **inte** in din hemliga nyckel på någon webbplats eller programvara som du inte litar på. - \n- Amethyst-utvecklare kommer **aldrig** att be dig om din hemliga nyckel. - \n- **Behåll en säker säkerhetskopia av din hemliga nyckel för kontoåterställning. Vi rekommenderar att du använder en lösenordshanterare. + ## Nyckelsäkerhetsråd + \n\nDitt konto är säkrat med en hemlig nyckel. Nyckeln är en lång följd av tecken som börjar med **nsec1**. Den som har tillgång till denna hemliga nyckel kan posta och ändra din identitet. + \n\n- **Lägg inte** din hemliga nyckel på någon webbplats eller i någon mjukvara som du inte litar på. + \n- Utvecklarna av Amethyst kommer **aldrig** att be om din hemliga nyckel. + \n- **Spara** en säkerhetskopia av din hemliga nyckel för återställning av kontot. Vi rekommenderar att du använder en lösenordshanterare. + För extra säkerhet kan du kryptera din nyckel med ett lösenord. Denna nyckel börjar med **ncryptsec1** och kan inte användas utan ditt lösenord. + \n\nOm du tappar bort ditt lösenord kommer du inte att kunna återställa din nyckel. + Misslyckades med att kryptera din privata nyckel Hemlig nyckel (nsec) kopierad till urklipp Kopiera min hemliga nyckel + Kryptera och kopiera min hemliga nyckel Autentisering misslyckades Biometri misslyckades med att autentisera ägaren av denna telefon Biometri misslyckades med att autentisera ägaren av denna telefon. Fel: %1$s @@ -451,6 +460,7 @@ För att aktivera denna funktion kräver det att Amethyst skickar ett NIP-24 meddelande (GiftWrapped, Förseglade Direkta och Gruppmeddelanden). NIP-24 är nytt och de flesta klienter har ännu inte implementerat det. Se till att mottagaren använder en kompatibel klient. Aktivera Publik + Ny offentlig eller privat grupp Privat Till Ämne @@ -467,6 +477,7 @@ Visa förhandsgranskning av URL När bilder ska laddas Kopiera till Urklipp + Kopiera npub till urklipp Kopiera URL till urklipp Kopiera anteckningens ID till urklipp Skapad den @@ -530,6 +541,7 @@ Kunde inte montera LNUrl från Lightning Address \"%1$s\". Kontrollera användarens konfiguration Mottagarens Lightning-tjänst på %1$s är inte tillgänglig. Den beräknades från Lightning Address \"%2$s\". Fel: %3$s. Kontrollera om servern är uppe och om Lightning Addressen är korrekt Kunde inte lösa %1$s. Kontrollera om du är ansluten, om servern är uppe och om Lightning Addressen %2$s är korrekt + Kunde inte lösa %1$s. Kontrollera om du är ansluten, om servern är uppe och om Lightning Addressen %2$s är korrekt.\n\nUndantag var: %3$s Kunde inte hämta fakturan från %1$s Fel vid tolkning av JSON från Lightning Address. Kontrollera användarens Lightning-konfiguration Återuppringnings-URL hittades inte i användarens konfiguration för Lightning Address-servern @@ -594,4 +606,58 @@ Servern gav inte en URL efter uppladdning Kunde inte ladda ner uppladdade medier från servern Kunde inte förbereda lokal fil att ladda upp: %1$s + Logga in med QR-kod + Rutt + Hem + Sök + Upptäck + Meddelanden + Aviseringar + Globalt + Kortfilmer + Säkerhetsfilter + Nytt inlägg + Nya kort: bilder eller videor + Nytt Community-meddelande + Öppna alla reaktioner på detta inlägg + Stäng alla reaktioner på detta inlägg + Svara + Boosta eller citera + Gilla + Zap + %1$s Profilbild + Relä %1$s + Expandera relälistan + Notalternativ + Relälistväljare + Omröstning + Inaktivera omröstning + Bitcoin-faktura + Avbryt Bitcoin-faktura + Avbryt Sälja en artikel + Zappraiser + Avbryt Zappraiser + Plats + Ta bort plats + Zap-split + Avbryt Zap-split + Lägg till varning för innehåll + Ta bort varning för innehåll + Visa npub som en QR-kod + Ogiltig adress + Amethyst fick en URI att öppna men den URI var ogiltig: %1$s + Zappa utvecklarna! + Din donation hjälper oss att göra skillnad. Varje sat räknas! + Donera nu + tillhandahölls av: + Denna version tillhandahölls av: + Version %1$s + Tack! + Maximal gräns + Begränsade skrivningar + Forkad från + FORK + Git Repository: %1$s + Webbplats: + Klona: diff --git a/app/src/main/res/values-sw-rKE/strings.xml b/app/src/main/res/values-sw-rKE/strings.xml index c56643d93..728922268 100644 --- a/app/src/main/res/values-sw-rKE/strings.xml +++ b/app/src/main/res/values-sw-rKE/strings.xml @@ -182,12 +182,6 @@ Funga kama Mpya Funga kama Zimejulikana Nakili Nakala za Ufunguo - ## Ufundi wa Nakala ya Ufunguo na Usalama - \n\nAkaunti yako inalindwa na ufunguo wa siri. Ufunguo ni mfululizo mrefu wa herufi zisizo na mpangilio, ukitangulia na **nsec1**. Mtu yeyote aliye na ufikiaji wa ufunguo wako wa siri anaweza kuchapisha maudhui kwa kutumia kitambulisho chako. - \n\n- **Usiweke** ufunguo wako wa siri kwenye tovuti au programu usio na imani. - \n- Watengenezaji wa Amethyst **hawata** kuuliza ufunguo wako wa siri. - \n- **Tunashauri** kuwa na nakala rudufu salama ya ufunguo wako wa siri kwa ajili ya kupata akaunti. Tunapendekeza kutumia meneja wa nywila. - Ufunguo wa siri (nsec) umeinakiliwa kwenye ubao wa kunakili Nakili Ufunguo Wangu wa Siri Uthibitishaji umeshindwa diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 99d6913fe..4a82494ad 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -171,12 +171,6 @@ புதியனவற்றைப் படித்ததாகக் குறி அனைத்தையும் படித்ததாகக் குறி காப்புச் சாவிகள் - ## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள் - \n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம். - \n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**. - \ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள். - \ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம். - ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது எனது ரகசிய சாவியை நகலெடுக்கவும் அங்கீகரிப்பு தோல்வியுற்றது diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 211b7c36c..55de47ddb 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -185,12 +185,6 @@ ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่ ทพเครื่องหมายว่าอ่านแล้วทั้งหมด สำรองข้อมูลรหัสลับ - ## เคล็ดลับการสํารองข้อมูลและความปลอดภัยที่สําคัญ - \n\n บัญชีของคุณมีความปลอดภัยด้วยรหัสลับ key คือสตริงสุ่มยาวที่ขึ้นต้นด้วย **nsec1** ทุกคนที่มีสิทธิ์เข้าถึงรหัสลับของคุณสามารถเผยแพร่เนื้อหาโดยใช้บัญชีของคุณได้ - \n\n- **อย่า** ใส่รหัสลับของคุณในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่เชื่อถือ - \n- นักพัฒนา Amethyst จะ **ไม่เคย** ขอรหัสลับของคุณ - \n- **เก็บ** สําเนาสํารองคีย์ลับของคุณไว้อย่างปลอดภัยสําหรับการกู้คืนบัญชี เราขอแนะนําให้ใช้ password manager. - คัดลอก Secret key (nsec) ลงคลิปบอร์ด คัดลอก secret key ของฉัน เกิดข้อผิดพลาดในการตรวจสอบ diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 962303166..c966009bc 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -33,9 +33,14 @@ Увійдіть з приватним ключем щоб лайкати дописи Не налаштовані запи. Натисніть та утримуйте для налаштування Увійдіть з приватним ключем щоб запати + Ви використовуєте відкритий ключ, і відкриті ключі доступні лише для читання. Увійдіть за допомогою закритого ключа, щоб мати можливість стежити + Ви використовуєте відкритий ключ, і відкриті ключі доступні лише для читання. Увійдіть за допомогою закритого ключа, щоб мати можливість скасувати підписку + Ви використовуєте відкритий ключ, і відкриті ключі доступні лише для читання. Увійдіть за допомогою закритого ключа, щоб мати можливість приховати слово або речення + Ви використовуєте відкритий ключ, і відкриті ключі доступні лише для читання. Увійдіть за допомогою закритого ключа, щоб мати можливість показати слово або речення Запи Перегляди Просувати + підсилено Цитувати Нова сума в sat Додати @@ -43,6 +48,8 @@ " та " "в каналі " Банер профілю + Успішно сплачено + Помилка при обробці повідомлення про помилку " Підписок" " Підписників" Профіль @@ -135,9 +142,17 @@ умови користування Треба прийняти умови користування Треба ввести ключ + Необхідно вказати ім\'я Увійти + Реєстрація + Створити обліковий запис + Як ми можемо до вас звертатися? + Не маєте облікового запису Nostr? + Вже маєте обліковий запис Nostr? + Створити новий обліковий запис Згенерувати ключ Завантаження стрічки + Завантаження облікового запису "Не вдалося завантажити відповіді: " Повторити Стрічка порожня. @@ -161,6 +176,7 @@ Показати спершу на %1$s Завжди перекладати на %1$s Не переводити з %1$s + Адреса Nostr ніколи зараз год @@ -175,15 +191,11 @@ Позначити всі нові прочитаними Позначити все прочитаним Резервне копіювання - ## Резервне копіювання та поради з безпеки - \n\nВаш акаунт захищений приватним ключем. Цей ключ – довгий випадковий рядок із приставкою **nsec1**. Той, хто володіє вашим приватним ключем, може писати від вашого імені. - \n\n- **Ніколи** не вставляйте ваш приватний ключ на сайтах або в додатках, яким ви не довіряєте. - \n- Розробники Amethyst **ніколи** не спитають ваш приватний ключ. - \n- **Збережіть** резервну копію приватного ключа, щоб мати можливість відновити акаунт. Радимо використовувати менеджер паролів. - Приватний ключ (nsec) скопійовано Скопіювати мій приватний ключ Помилка входу + Не вдалося виконати біометрику для автентифікації власника цього телефону + Не вдалося виконати біометрику для автентифікації власника цього телефону. Помилка: %1$s Помилка "Створено %1$s" "Картинка значка-нагороди %1$s" @@ -260,8 +272,11 @@ Мінімальний зап Максимальний зап Консенсус + (0–100)% Закриття після днів + Неможливо проголосувати + Опитування закрито для нових голосів Сума запу У цьому опитуванні можна голосувати тільки один раз "Пошук події %1$s" @@ -271,6 +286,7 @@ Дякую всім вам за роботу! Створити та Додати Автор опитування не може голосувати + Що це значить? Фото не змінилося після публікації Фото змінено. Автор міг не помітити зміну Додати фото @@ -293,8 +309,24 @@ LnAddress або @User Ваші релеї (NIP-95) Файл зберігається на ваших релеях. Новий NIP: може не працювати в інших клієнтах + Налаштування Tor/Orbot + Підключення через налаштування Orbot + Від\'єднатися від вашого Orbot/Tor? + Ваші дані буде негайно перенесено у звичайну мережу + Так + Ні + Слідкуйте за списком Всі підписки Глобально + ## Підключіться через Tor з Orbot + \n\n1. Установіть [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. Запустіть Orbot + \n3. В Orbot перевірте порт Socks. За умовчанням використовується 9050 + \n4. Якщо необхідно, змініть порт в Orbot + \n5. Налаштуйте порт Socks на цьому екрані + \n6. Натисніть кнопку «Активувати», щоб використовувати Orbot як проксі + Невірний номер порту + Відключити Tor/Orbot Особисті повідомлення Сповіщення про вхідні повідомлення Запи @@ -302,4 +334,174 @@ %1$s sat Від %1$s за %1$s + Сповіщення: + Долучитися до розмови + ID користувача або групи + Створити + Долучитися + Сьогодні + Попередження про контент + Цей пост містить чутливий контент, який деякі люди можуть знайти образливим + Завжди приховувати чутливий контент + Завжди показувати чутливий контент + Завжди показувати попередження про контент + Рекомендовані: + Фільтрувати спам від незнайомців + Попереджати, коли публікації містять звіти про ваші підписки + Новий символ реакції + Для цього користувача не вибрано жодного типу реакції. Довге натискання на кнопку серця, щоб змінити + Додає цільову суму сат, яку потрібно отримати для цієї посади. Клієнти, які підтримують, можуть відображати це як індикатор прогресу, щоб стимулювати пожертвування + Власник + Версія + Програмне забезпечення + Підтримувані NIP + Плата за вступ + Посилання на оплату + Обмеження + Країни + Мови + Теги + Політика публікації + Довжина повідомлення + Підписки + Фільтри + Довжина Id підписки + Мінімальний префікс + Максимальна кількість тегів події + Тривалість контенту + Мінімальний PoW + Оплата + Надіслати до Zap Wallet + Токен скопійовано до буферу обміну + НАЖИВО + ОФЛАЙН + ЗАВЕРШЕНО + ЗАПЛАНОВАНО + Пряма трансляція Офлайн + Пряму трансляцію завершено + Вихід з облікового запису видаляє всю вашу локальну інформацію. Переконайтеся, що ваші особисті ключі були збережені, щоб уникнути втрати вашого облікового запису. Ви хочете продовжити? + Відстежувані теги + Торгівельний майданчик + Наживо + Спільнота + Чати + Затверджені публікації + Ця група не має опису або правил. Зверніться до автора, щоб додати один + Ця спільнота не має опису або правил. Зверніться до автора, щоб додати один + Чутливий контент + Додає попередження про чутливий контент перед показом цього контенту + Налаштування + Завжди + Тільки через Wi-Fi + Ніколи + Система + Світла + Темна + Налаштування додатку + Мова + Оформлення + Попередній перегляд + Відтворення відео + Перегляд URL-адреси + Розширена прокрутка + Приховати Nav панель під час прокручування + Завантажити зображення + Спам + Звук вимкнено. Натисніть, щоб увімкнути звук + Звук увімкнено. Натисніть, щоб вимкнути звук + Пошук локальних і віддалених записів + Адресу Nostr було верифіковано + Перевірка адреси Nostr + Вибрати/Скасувати все + За замовчуванням + Виберіть ретранслятор, щоб продовжити + Переслати Zaps до: + Підтримання клієнтів переведе його на LNAddress або профіль користувача нижче замість ваших + Надати місцеперебування як + Нова функція + Активувати + Тема бесіди + "\@User1, @User2, @User3" + Учасники цієї групи + Пояснення для учасників + Зміна назви нових цілей. + Вставити з буферу + Для інтерфейсу додатку + Темна, світла або системна тема + Автоматично завантажувати зображення і GIF + Автоматично відтворювати відео та GIF-зображення + Попередній перегляд URL + Під час завантаження зображень + Скопіювати в буфер + Скопіювати URL в буфер обміну + Копіювати ідентифікатор нотатки в буфер обміну + Створено в + Правила + Увійти з Amber + Оновіть свій статус + Помилка при обробці повідомлення про помилку + Не вдалося надіслати файл Zap + Написати користувачу + ОК + Не вдалося зв’язатися з %1$s: %2$s + Не вдалося зв’язатися з %1$s: %2$s + Не вдалося обробити результат від %1$s: %2$s + %1$s помилка з кодом %2$s + Активно для: + Головна + DMs + Чати + Глобально + Пошук + Знайти та додати користувача + Ім\'я користувача або ім\'я для відображення + 25 + Сплачено + Гаманець: %1$s + Помилка при відкритті додатка для входу + Додаток для входу не знайдено. Перевірте чи не було видалено додаток + Заявку на вхід відхилено + Приховати нове слово або речення + Зображення профілю + Показувати зображення профілю + Вибрати опцію + Не вдалося сплатити рахунок + Не вдалося зняти + Не вдалося налаштувати Wallet Connect + Помилка аналізу рядка підключення NIP-47. Перевірте, чи це правильно, у вашого постачальника Wallet: %1$s. Помилка: %2$s + Помилка аналізу рядка підключення NIP-47. Перевірте, чи це правильно, у вашого постачальника Wallet: %1$s. + Mint надав таке повідомлення про помилку: %1$s + Cashu токени вже витрачені. + Отримано Cashu + %1$s sats були відправлені в ваш гаманець. (Fees: %2$s sats) + У системі не знайдено сумісний гаманець Cashu + Не вдалося отримати рахунок з серверів отримувача + Постачальник підключення гаманця повернув наступну помилку: %1$s + Не вдалося підключитися до Tor + Це абсолютно нова одиниця, в оригінальній коробці + Як нове + Він використовується, але немає ознак використання + Добре + Він має декілька поверхових знаків використання + Непогано + Він все ще знаходиться у прийнятній і функціональній формі + Одяг + Аксесуари + Електроніка + Меблі + Колекційні елементи + Книги + Домашні тварини + Спорт + Фітнес + Мистецтво + Ремесла + Інше + Не вдалося завантажити фото + Не вдалося відкрити стиснутий файл + Помилка стискання медіафайлу: %1$s + Помилка завантаження: %1$s + Сервер не задав URL після завантаження + Не вдалося завантажити завантажені медіафайли з сервера + Не вдалося підготувати локальний файл для завантаження: %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8f9adfa0f..b6cdbb6e2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -3,6 +3,7 @@ 对准二维码 显示二维码 头像图片 + 头像图片 扫描二维码 仍然显示 帖文被标记为不当 @@ -90,6 +91,8 @@ 添加中继器 显示名称 我的显示名称 + Ostrich McAwesome + 欢迎! 用户名 我的用户名 关于我 @@ -143,7 +146,14 @@ 使用条款 需要接受条款 需要密钥 + 名称必填 登录 + 注册 + 创建帐户 + 我们应该如何称呼你? + 还没有 Nostr 帐户? + 已经有 Nostr 帐户? + 创建新帐户 生成新密钥 正在加载 正在载入帐户 @@ -185,11 +195,6 @@ 将所有新内容标记为已读 将所有内容标记为已读 备份密匙 - ## 备份与安全提示 - \n\n您的帐户由一个私人密钥保护。 密钥是以**nsec1**开头的长随机字符串。任何拥有您的私人密钥的人都可以使用您的身份发布内容。 - \n\n- **不要**将您的私人密钥添加到您不信任的任何网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求您提供私人密钥。 - \n- **请**保留您的私人密钥的安全备份,以备帐户恢复。 我们建议使用密码管理器。 私人密钥(nsec)已复制到剪贴板 复制我的私人密钥 身份验证失败 @@ -443,6 +448,7 @@ 启用此模式需要 Amethyst 发送一条 NIP-24 消息(包装的、密封的私信和群聊消息)。因为 NIP-24 是新的,大多数客户端尚未执行。请确保接收方正在使用兼容的客户端。 启用 公开 + 新建公开或私人群组 私人 主题 @@ -459,6 +465,7 @@ 显示 URL 预览 何时加载图像 复制到剪贴板 + 复制 npub 到剪贴板 复制链接到剪贴板 复制笔记 ID 到剪贴板 创建于 @@ -522,6 +529,7 @@ 无法从闪电地址集合LNURL\"%1$s\"。请检查用户设置 %1$s 的接收者闪电服务不可用。它是根据雷电地址\"%2$s\"计算的。 错误: %3$s。请检查服务器是否已经上线,闪电地址是否正确 无法解析 %1$s。请检查你是否已连接,服务器是否启动,以及闪电地址 %2$s 是否正确 + 无法解析 %1$s。请检查你是否已连接,服务器是否启动,以及闪电地址 %2$s 是否正确。\n\n异常为:%3$s 无法从 %1$s 获取发票 从闪电地址解析JSON出错。请检查用户的打闪设置 在用户闪电地址服务器配置中找不到回调URL @@ -586,4 +594,44 @@ 上传后服务器没有提供 URL 无法从服务器下载上传的媒体 无法准备要上传的本地文件:%1$s + 使用二维码登录 + 路径 + 主页 + 搜索 + 发现 + 消息 + 通知 + 全球 + 短篇 + 安全滤镜 + 新帖子 + 新短篇媒体:图像或视频 + 新社区笔记 + 展开对此帖子的所有回应 + 收起对此帖子的所有回应 + 回复 + 提升或引用 + 点赞 + 打闪 + %1$s 的个人头像 + 中继器 %1$s + 展开中继列表 + 笔记选项 + 中继列表选择器 + 投票 + 停用投票 + 比特币发票 + 取消比特币发票 + 取消出售物品 + Zapraiser + 取消 Zapraiser + 地点 + 移除地点 + 打闪拆分 + 取消打闪拆分 + 添加内容警告 + 移除内容警告 + 将 npub 显示为二维码 + 地址无效 + Amethyst 收到了要打开的 URI,但该 uri 无效:%1$s diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 9b4f4ae42..a5afbefe4 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -172,12 +172,6 @@ 將所有新內容標記為已讀 將所有內容標記為已讀 備份密匙 - ## 備份與安全提示 - \n\n您的帳户由一個私人密鑰保護。 密鑰是以**nsec1**開頭的長隨機字符串。任何擁有您的私人密鑰的人都可以使用您的身份發佈內容。 - \n\n- **不要**將您的私人密鑰添加到您不信任的任何網站或軟件,亦不要在網上公開。 - \n- Amethyst 開發人員**永遠不會**要求您提供私人密鑰。 - \n- **請**保留您的私人密鑰的安全備份,以備帳户恢復。 我們建議使用密碼管理器。 - 私人密鑰(nsec)已複製到剪貼板 複製我的私人密鑰 身份驗證失敗 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 223f7b1fa..2d0cb6793 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -3,6 +3,7 @@ 對準 QR 顯示 QR 頭像圖片 + 頭像圖片 掃描 QR 一直顯示 貼文被標記為不當 @@ -90,6 +91,8 @@ 添加中繼器 顯示名稱 我的顯示名稱 + Ostrich McAwesome + 歡迎! 用户名 我的用户名 關於我 @@ -143,7 +146,14 @@ 使用條款 需要接受條款 需要密鑰 + 名稱為必填 登錄 + 註冊 + 建立帳戶 + 我們應該如何稱呼你? + 還沒有 Nostr 帳戶? + 已經有 Nostr 帳戶? + 創建新帳戶 生成新密鑰 正在加載 正在加載帳戶 @@ -185,12 +195,6 @@ 將所有新內容標記為已讀 將所有內容標記為已讀 備份密鑰 - ## 備份與安全提示 - \n\n您的帳户由一個私人密鑰保護。 密鑰是以**nsec1**開頭的長隨機字符串。任何擁有您的私人密鑰的人都可以使用您的身份發佈內容。 - \n\n- **不要**將您的私人密鑰添加到您不信任的任何網站或軟件,亦不要在網上公開。 - \n- Amethyst 開發人員**永遠不會**要求您提供私人密鑰。 - \n- **請**保留您的私人密鑰的安全備份,以備帳户恢復。 我們建議使用密碼管理器。 - 私人密鑰(nsec)已複製到剪貼板 複製我的私人密鑰 身份驗證失敗 @@ -444,6 +448,7 @@ 啟用此模式需要 Amethyst 發送一條 NIP-24 消息(包裝的、密封的私信和群聊消息)。因為 NIP-24 是新的,大多數客戶端尚未執行。請確保接收方正在使用兼容的客戶端。 啟用 公開 + 新建公開或私人群組 私人 主題 @@ -460,6 +465,7 @@ 顯示鏈接預覽 何時加載圖像 複製至剪貼簿 + 複製 npub 至剪貼簿 將 URL 複製到剪貼簿 將筆記 ID 複製到剪貼簿 創建於 @@ -523,6 +529,7 @@ 無法從閃電地址集合 LNURL “%1$s”。請檢查用戶設置 %1$s 的接收方閃電服務不可用。它是根據閃電地址“%2$s”計算的。錯誤:%3$s。請檢查伺服器是否已上線,閃電地址是否正確 無法解析 %1$s。請檢查你是否已連接,伺服器是否啟動,以及閃電地址 %2$s 是否正確 + 無法解析 %1$s。請檢查你是否已連接,服務器是否啓動,以及閃電地址 %2$s 是否正確。\n\n異常爲:%3$s 無法從 %1$s 獲取發票 從閃電地址解析 JSON 發生錯誤。請檢查用戶的閃電設置 在用戶閃電地址伺服器配置中找不到回調 URL @@ -587,4 +594,44 @@ 上傳後伺服器沒有提供 URL 無法從伺服器下載上傳的媒體 無法準備要上傳的本地文件:%1$s + 使用二維碼登錄 + 路徑 + 主頁 + 搜索 + 發現 + 訊息 + 通知 + 全球 + 短篇 + 安全濾鏡 + 新帖子 + 新短篇:圖像或視頻 + 新社區筆記 + 展開所有對此帖子的回應 + 收起所有對此帖子的回應 + 回覆 + 提升或引用 + 點贊 + 打閃 + %1$s 的頭像圖片 + 中繼器 %1$s + 展開中繼列表 + 筆記選項 + 中繼列表選擇器 + 投票 + 停用投票 + 比特幣發票 + 取消比特幣發票 + 取消出售物品 + Zapraiser + 取消 Zapraiser + 地點 + 移除地點 + 打閃拆分 + 取消打閃拆分 + 添加內容警告 + 移除內容警告 + 將 npub 顯示爲二維碼 + 地址無效 + Amethyst 收到了要打開的 URI,但該 URI 無效:%1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 834e7d8f0..d1cf305cc 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -176,11 +176,6 @@ 将所有新内容标记为已读 将所有内容标记为已读 备份密匙 - ## 备份与安全提示 - \n\n您的帐户由一个私人密钥保护。 密钥是以**nsec1**开头的长随机字符串。任何拥有您的私人密钥的人都可以使用您的身份发布内容。 - \n\n- **不要**将您的私人密钥添加到您不信任的任何网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求您提供私人密钥。 - \n- **请**保留您的私人密钥的安全备份,以备帐户恢复。 我们建议使用密码管理器。 私人密钥(nsec)已复制到剪贴板 复制我的私人密钥 身份验证失败 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d272c4f0..f015ba35f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,7 +3,8 @@ Amethyst Debug Point to the QR Code Show QR - Profile Image + Profile Picture + Your Profile Picture Scan QR Show Anyway Post was muted or reported by @@ -16,6 +17,7 @@ Spam Impersonation Illegal Behavior + Other Unknown Relay Icon Unknown Author @@ -23,6 +25,9 @@ Copy Author ID Copy Note ID Broadcast + Timestamp it + Timestamp: Pending Confirmations + OTS: Pending Request Deletion Block / Report @@ -46,6 +51,7 @@ Boost boosted Quote + Fork New Amount in Sats Add "replying to " @@ -141,12 +147,15 @@ Clear App Logo nsec.. or npub.. + password to open the key Show Password Hide Password Invalid key + Invalid key: %1$s "I accept the " terms of use Acceptance of terms is required + Password is required Key is required A name is required Login @@ -199,15 +208,22 @@ Mark all New as read Mark all as read Backup Keys - + ## Key Backup and Safety Tips - \n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity. + \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. \n\n- Do **not** put your secret key in any website or software you do not trust. - \n- Amethyst developers will **never** ask you for your secret key. + \n- Amethyst developers will **never** ask for your secret key. \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. + + For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. + \n\nIf you lose your password, you will not be able to recover your key. + + + Failed to encrypt your private key Secret key (nsec) copied to clipboard Copy my secret key + Encrypt and copy my secret key Authentication failed Biometrics failed to authenticate the owner of this phone Biometrics failed to authenticate the owner of this phone. Error: %1$s @@ -523,6 +539,7 @@ Activate Public + New Public or Private Group Private To Subject @@ -543,6 +560,7 @@ When to load images Copy to clipboard + Copy npub to clipboard Copy URL to clipboard Copy Note ID to clipboard @@ -625,6 +643,7 @@ Could not assemble LNUrl from Lightning Address \"%1$s\". Check the user\'s setup The receiver\'s lightning service at %1$s is not available. It was calculated from the lightning address \"%2$s\". Error: %3$s. Check if the server is up and if the lightning address is correct Could not resolve %1$s. Check if you are connected, if the server is up and if the lightning address %2$s is correct + Could not resolve %1$s. Check if you are connected, if the server is up and if the lightning address %2$s is correct.\n\nException was: %3$s Could not fetch invoice from %1$s Error Parsing JSON from Lightning Address. Check the user\'s lightning setup Callback URL not found in the User\'s lightning address server configuration @@ -701,5 +720,76 @@ Server did not provide a URL after uploading Could not download uploaded media from the server Could not prepare local file to upload: %1$s + Edit draft + + Login with QR Code + Route + Home + Search + Discover + Messages + Notifications + Global + Shorts + Security Filters + + New Post + New Shorts: images or videos + New Community Note + + Open all reactions to this post + Close all reactions to this post + + Reply + Boost Or Quote + Like + Zap + + Profile Picture of %1$s + Relay %1$s + Expand relay list + Note options + Relay list selector + Poll + Disable Poll + + Bitcoin Invoice + Cancel Bitcoin Invoice + Cancel Sell an Item + + Zapraiser + Cancel Zapraiser + + Location + Remove Location + + Zap splits + Cancel Zap split + + Add content warning + Remove content warning + Show npub as a QR code + + Invalid address + Amethyst received a URI to open but that uri was invalid: %1$s + + Zap the Devs! + Your donation helps us make a difference. Every sat counts! + Donate Now + was brought to you by: + This version was brought to you by: + Version %1$s + Thank you! + Max Limit + Restricted Writes + Forked from + FORK + Git Repository: %1$s + Web: + Clone: + OTS: %1$s + + Timestamp Proof + There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 23d8f76e2..b1bd71d23 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -6,7 +6,7 @@ - + diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 4d9bcc839..f2921c4be 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -33,6 +33,7 @@ import com.google.mlkit.nl.translate.TranslatorOptions import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.service.checkNotInMainThread +import kotlinx.coroutines.CancellationException import java.util.concurrent.Executors import java.util.regex.Pattern @@ -161,7 +162,8 @@ object LanguageTranslatorService { val short = "A$counter" counter++ returningList.put(short, tag) - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e } } return returningList @@ -177,7 +179,8 @@ object LanguageTranslatorService { val short = "A$counter" counter++ returningList.put(short, lnInvoice) - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e } } return returningList diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index a3dd0067f..3b28a3843 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index cd1aadf00..caccf4374 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.util.Log import com.google.firebase.messaging.FirebaseMessaging import com.vitorpamplona.amethyst.AccountInfo +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await @@ -38,6 +39,7 @@ object PushNotificationUtils { try { RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) } catch (e: Exception) { + if (e is CancellationException) throw e Log.e("Firebase token", "failed to get firebase token", e) } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 74d0ae0bd..10a9ad259 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index cc2e2c5b2..41f4eb250 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -169,6 +169,7 @@ private fun TranslationMessage( withStyle(clickableTextStyle) { pushStringAnnotation("langSettings", true.toString()) append(stringResource(R.string.translations_auto)) + pop() } append("-${stringResource(R.string.translations_translated_from)} ") @@ -176,6 +177,7 @@ private fun TranslationMessage( withStyle(clickableTextStyle) { pushStringAnnotation("showOriginal", true.toString()) append(Locale(source).displayName) + pop() } append(" ${stringResource(R.string.translations_to)} ") @@ -183,6 +185,7 @@ private fun TranslationMessage( withStyle(clickableTextStyle) { pushStringAnnotation("showOriginal", false.toString()) append(Locale(target).displayName) + pop() } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt index 311ddb9f3..e06bdc333 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index e3fd477e9..43645c380 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -54,10 +55,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") - assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.Note) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.Note)?.hex, ) assertEquals("", result?.restOfWord) } @@ -67,10 +68,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - assertEquals(Nip19.Type.USER, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.NPub) assertEquals( "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.NPub)?.hex, ) assertEquals("", result?.restOfWord) } @@ -80,10 +81,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.Note) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.Note)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -93,10 +94,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.NPub) assertEquals( "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.NPub)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -106,10 +107,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.Note) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.Note)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -119,10 +120,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.NPub) assertEquals( "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.NPub)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -134,10 +135,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", ) - assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.Note) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.Note)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -149,10 +150,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", ) - assertEquals(Nip19.Type.USER, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.NPub) assertEquals( "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.NPub)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -164,10 +165,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", ) - assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.Note) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.Note)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -179,10 +180,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", ) - assertEquals(Nip19.Type.USER, result?.key?.type) + assertTrue(result?.key?.entity is Nip19Bech32.NPub) assertEquals( "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, + (result?.key?.entity as? Nip19Bech32.NPub)?.hex, ) assertEquals(",", result?.restOfWord) } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt index 7912d7703..839fed1e7 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt index 1e2abe7ed..a8ee63a46 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt deleted file mode 100644 index 071119be9..000000000 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt +++ /dev/null @@ -1,78 +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.service - -import junit.framework.TestCase.assertEquals -import org.junit.Test - -class Nip30Test { - @Test() - fun parseEmoji() { - val input = "Alex Gleason :soapbox:" - - assertEquals( - listOf("Alex Gleason ", ":soapbox:", ""), - Nip30CustomEmoji().buildArray(input), - ) - } - - @Test() - fun parseEmojiInverted() { - val input = ":soapbox:Alex Gleason" - - assertEquals( - listOf("", ":soapbox:", "Alex Gleason"), - Nip30CustomEmoji().buildArray(input), - ) - } - - @Test() - fun parseEmoji2() { - val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" - - assertEquals( - listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), - Nip30CustomEmoji().buildArray(input), - ) - - println(Nip30CustomEmoji().buildArray(input).joinToString(",")) - } - - @Test() - fun parseEmoji3() { - val input = "hello vitor: how can I help:" - - assertEquals( - listOf("hello vitor: how can I help:"), - Nip30CustomEmoji().buildArray(input), - ) - } - - @Test() - fun parseJapanese() { - val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)" - - assertEquals( - listOf("\uD883\uDEDE\uD883\uDEDE麺の", ":x30EDE:", "。:\uD883\uDEDE:(Violation of NIP-30)"), - Nip30CustomEmoji().buildArray(input), - ) - } -} diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt index 307294ebf..44a350500 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt index e2e2cebe2..7d4475efd 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/benchmark/build.gradle b/benchmark/build.gradle index 61f8934fe..61ab23cfb 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -28,7 +28,7 @@ android { testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner' } - testBuildType = "release" + testBuildType = "benchmark" buildTypes { debug { // Since debuggable can"t be modified by gradle for library modules, @@ -52,8 +52,9 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.2' + androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.2.3' androidTestImplementation project(path: ':quartz') + androidTestImplementation project(path: ':commons') // Add your dependencies here. Note that you cannot benchmark code // in an app module this way - you will need to move any code you diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt deleted file mode 100644 index ddfbb0fd8..000000000 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt +++ /dev/null @@ -1,174 +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.benchmark - -import androidx.benchmark.junit4.BenchmarkRule -import androidx.benchmark.junit4.measureRepeated -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.events.Event -import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Benchmark, which will execute on an Android device. - * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the - * result. Modify your code to see how it affects performance. - */ -@RunWith(AndroidJUnit4::class) -class EventBenchmark { - val payload1 = - """[ - "EVENT", - "40b9", - { - "id":"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf", - "kind":1, - "pubkey":"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d", - "created_at":1677940007, - "content":"I got asked about follower count again today. Why does my follower - count go down when I delete public relays (in our list) and replace them with - filter.nostr.wine? \\n\\nI’ll give you one final explanation to rule them all. - First, let’s go over how clients calculate your follower count.\\n\\n1. Your - client sends a request to all your connected relays asking for accounts who - follow you\\n2. Relays answer back with the events requested\\n3. The client - aggregates the event total and displays it\\n\\nEach relay has a set limit on - how many stored events it will return per request. For some relays it’s 500, - others 1000, some as high as 5000. Let’s say for simplicity that all your - public relays use 500 as their limit. If you ask 10 relays for your followers - the max possible answer you can get is 5000. That won’t change if you have - 20,000 followers or 100,000. You may get back a “different” 5000 each time, - but you’ll still cap out at 5000 because that is the most events your client - will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you - replace 10 public relays with only filter.nostr.wine, the MOST followers you - will ever get back from our filter relay is 2000. That doesn’t mean you only - have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as - you are writing to and reading from the same public relays, neither your reach - nor any content was lost. That concludes my TED talk. I hope you all have a - fantastic day and weekend.", - "tags":[ - - ], - "sig":"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8" - } -]""" - - val payload2 = - """{ - "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\n - Iris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\n - Snort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\n - Amethyst agrees with Astral which I suspect are both wrong. - nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk - nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", - "created_at": 1683596206, - "id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", - "kind": 1, - "pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", - "sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", - "tags": [ - ["e","27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3","","root"], - ["e","be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc","","reply"], - ["p","22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"], - ["p","22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"], - ["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"], - ["p","ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"], - ["p","0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"], - ["p","8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"], - ["p","63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"], - ["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"], - ["p","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"] - ], - "seenOn": [ - "wss://nostr.wine/" - ] -} -""" - - @get:Rule val benchmarkRule = BenchmarkRule() - - @Test - fun parseREQString() { - benchmarkRule.measureRepeated { Event.mapper.readTree(payload1) } - } - - @Test - fun parseEvent() { - val msg = Event.mapper.readTree(payload1) - - benchmarkRule.measureRepeated { Event.fromJson(msg[2]) } - } - - @Test - fun checkSignature() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) - benchmarkRule.measureRepeated { - // Should pass - assertTrue(event.hasVerifiedSignature()) - } - } - - @Test - fun checkIDHashPayload1() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) - - benchmarkRule.measureRepeated { - // Should pass - assertTrue(event.hasCorrectIDHash()) - } - } - - @Test - fun checkIDHashPayload2() { - val event = Event.fromJson(payload2) - - benchmarkRule.measureRepeated { - // Should pass - assertTrue(event.hasCorrectIDHash()) - } - } - - @Test - fun toMakeJsonForID() { - val event = Event.fromJson(payload2) - - benchmarkRule.measureRepeated { assertNotNull(event.makeJsonForId()) } - } - - @Test - fun sha256() { - val event = Event.fromJson(payload2) - val byteArray = event.makeJsonForId().toByteArray() - - benchmarkRule.measureRepeated { - // Should pass - assertNotNull(CryptoUtils.sha256(byteArray)) - } - } -} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt new file mode 100644 index 000000000..aecc2dad3 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt @@ -0,0 +1,155 @@ +/** + * 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.ExpandableTextCutOffCalculator +import com.vitorpamplona.amethyst.commons.nthIndexOf +import junit.framework.TestCase +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ExpandableTextCutOffCalculatorBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun computeTestCase1() { + benchmarkRule.measureRepeated { + TestCase.assertEquals( + 293, + testCase1.nthIndexOf('\n', 10), + ) + } + } + + @Test + fun computeTestCase2() { + benchmarkRule.measureRepeated { + TestCase.assertEquals( + 423, + testCase2.nthIndexOf('\n', 10), + ) + } + } + + @Test + fun computeTestCase1All() { + benchmarkRule.measureRepeated { + TestCase.assertEquals( + 293, + ExpandableTextCutOffCalculator.indexToCutOff(testCase1), + ) + } + } + + @Test + fun computeTestCase2All() { + benchmarkRule.measureRepeated { + TestCase.assertEquals( + 355, + ExpandableTextCutOffCalculator.indexToCutOff(testCase2), + ) + } + } + + @Test + fun computeTestCase3All() { + benchmarkRule.measureRepeated { + TestCase.assertEquals( + 65, + ExpandableTextCutOffCalculator.indexToCutOff(testCase3), + ) + } + } + + val testCase1 = """ +#Amethyst v0.83.10 + +تحديث جديد لـ Amethyst بإصدار 0.83.10 مع تعديلات وإضافات جديدة + +: NIP-92 إصلاحات الأخطاء + + الإضافات الجديدة: + - يتضمن رابط المنتج في الرسالة الأولى من المشتري في السوق + - يضيف دعمًا لـ NIP-92 في الرسائل العامة والرسائل المباشرة الجديدة (NIP-17). يبقى NIP-54 في NIP-04 DMs + - إضافة التمرير الأفقي إلى أزرار الإجراءات في شاشة النشر الجديد لإصلاح الأزرار المخفية جزئيًا في الشاشات الصغيرة/الرفيعة. + + اصلاحات الشوائب: + - إصلاحات التعطل مع مبلغ Zap مخصص غير صالح + - يعمل على إصلاح مشكلات إعادة اتصال التتابع عندما يقوم المرحل بإغلاق الاتصال + - إصلاح الحشو العلوي للملاحظة المقتبسة في المنشور + - تحسين استخدام الذاكرة للمستخدم المرئي وعلامة URL في المشاركات الجديدة + + الترجمات المحدثة: + - الفارسية بواسطة + - الفرنسية والإنجليزية، المملكة المتحدة بواسطة + - الأوكرانية + - الإسبانية والإسبانية والمكسيك والإسبانية والولايات المتحدة بواسطة + - العربية + + تحسينات جودة الكود: + - تحديثات لنظام Android Studio 2023.1.1 Patch 2 + + + + +nostr:nevent1qqszq7kl888sw0c5rpvepn8w373zt0jrw8864x8lkauxxw335s66rzgppemhxue69uhkummn9ekx7mp0qgsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqrqsqqqqqpaax7m2 +""" + + val testCase2 = """ +#Amethyst v0.83.10: NIP-92 and Bug Fixes + +New Additions: +- Includes a link to the product in the first message from the buyer in the marketplace +- Adds support for NIP-92 in public messages and new DMs (NIP-17). NIP-54 stays in NIP-04 DMs +- Adds Horizontal Scroll to the action buttons in the New Post screen to partially fix hidden buttons in small/thin screens. + +Bugfixes: +- Fixes crash with an invalid custom Zap Amount +- Fixes relay re-connection issues when the relay closes a connection +- Fixes the top padding of the quoted note in a post +- Optimizes memory use of the visual user and url tagger in new posts + +Updated translations: +- Persian by nostr:npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk +- French and English, United Kingdom by nostr:npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t +- Ukrainian by crowdin.com/profile/liizzzz +- Spanish, Spanish, Mexico and Spanish, United States by nostr:npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Arabic by nostr:npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t + +Code Quality Improvements: +- Updates to Android Studio 2023.1.1 Patch 2 + +Download: +- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk ) +- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-fdroid-universal-v0.83.10.apk ) +""" + + val testCase3 = """#100aDayUntil100k +Day 5 ✔️ + +Seems like they may be getting easier""" +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt new file mode 100644 index 000000000..c3408f582 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt @@ -0,0 +1,224 @@ +/** + * 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.linkedin.urls.detection.UrlDetector +import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.amethyst.commons.HashTagSegment +import com.vitorpamplona.amethyst.commons.ImageSegment +import com.vitorpamplona.amethyst.commons.LinkSegment +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RichTextParserBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun parseApkUrl() { + benchmarkRule.measureRepeated { + assertNull( + RichTextParser().parseMediaUrl( + "https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk", + EmptyTagList, + ), + ) + } + } + + @Test + fun parseImageUrl() { + benchmarkRule.measureRepeated { + assertTrue( + RichTextParser().parseText( + "first https://m.primal.net/HeKw.jpg second", + EmptyTagList, + ).paragraphs[0].words[1] is ImageSegment, + ) + } + } + + @Test + fun parseNoSchemeUrl() { + benchmarkRule.measureRepeated { + assertTrue( + RichTextParser().parseText( + "first amethyst.social second", + EmptyTagList, + ).paragraphs[0].words[1] is LinkSegment, + ) + } + } + + @Test + fun parseHashtag() { + benchmarkRule.measureRepeated { + assertTrue( + RichTextParser().parseText( + "first #amethyst second", + EmptyTagList, + ).paragraphs[0].words[1] is HashTagSegment, + ) + } + } + + @Test + fun computeTestCase1All() { + benchmarkRule.measureRepeated { + RichTextParser().parseText(testCase1, EmptyTagList) + } + } + + @Test + fun computeTestCase2All() { + benchmarkRule.measureRepeated { + RichTextParser().parseText(testCase2, EmptyTagList) + } + } + + @Test + fun computeTestCase2UrlDetector() { + benchmarkRule.measureRepeated { + UrlDetector(testCase2, UrlDetectorOptions.Default).detect() + } + } + + @Test + fun computeTestCase2UrlDetectorWJapanese() { + benchmarkRule.measureRepeated { + UrlDetector(testCaseJapanese, UrlDetectorOptions.Default).detect() + } + } + + @Test + fun computeTestCase2ParseUrls() { + benchmarkRule.measureRepeated { + RichTextParser().parseValidUrls(testCase2) + } + } + + @Test + fun computeTestCase3All() { + benchmarkRule.measureRepeated { + RichTextParser().parseText(testCase3, EmptyTagList) + } + } + + @Test + fun computeTestCaseJapanese() { + benchmarkRule.measureRepeated { + RichTextParser().parseText(testCaseJapanese, testCaseJapaneseTags) + } + } + + val testCase1 = """ +#Amethyst v0.83.10 + +تحديث جديد لـ Amethyst بإصدار 0.83.10 مع تعديلات وإضافات جديدة + +: NIP-92 إصلاحات الأخطاء + + الإضافات الجديدة: + - يتضمن رابط المنتج في الرسالة الأولى من المشتري في السوق + - يضيف دعمًا لـ NIP-92 في الرسائل العامة والرسائل المباشرة الجديدة (NIP-17). يبقى NIP-54 في NIP-04 DMs + - إضافة التمرير الأفقي إلى أزرار الإجراءات في شاشة النشر الجديد لإصلاح الأزرار المخفية جزئيًا في الشاشات الصغيرة/الرفيعة. + + اصلاحات الشوائب: + - إصلاحات التعطل مع مبلغ Zap مخصص غير صالح + - يعمل على إصلاح مشكلات إعادة اتصال التتابع عندما يقوم المرحل بإغلاق الاتصال + - إصلاح الحشو العلوي للملاحظة المقتبسة في المنشور + - تحسين استخدام الذاكرة للمستخدم المرئي وعلامة URL في المشاركات الجديدة + + الترجمات المحدثة: + - الفارسية بواسطة + - الفرنسية والإنجليزية، المملكة المتحدة بواسطة + - الأوكرانية + - الإسبانية والإسبانية والمكسيك والإسبانية والولايات المتحدة بواسطة + - العربية + + تحسينات جودة الكود: + - تحديثات لنظام Android Studio 2023.1.1 Patch 2 + + + + +nostr:nevent1qqszq7kl888sw0c5rpvepn8w373zt0jrw8864x8lkauxxw335s66rzgppemhxue69uhkummn9ekx7mp0qgsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqrqsqqqqqpaax7m2 +""" + + val testCase2 = """ +#Amethyst v0.83.10: NIP-92 and Bug Fixes + +New Additions: +- Includes a link to the product in the first message from the buyer in the marketplace +- Adds support for NIP-92 in public messages and new DMs (NIP-17). NIP-54 stays in NIP-04 DMs +- Adds Horizontal Scroll to the action buttons in the New Post screen to partially fix hidden buttons in small/thin screens. + +Bugfixes: +- Fixes crash with an invalid custom Zap Amount +- Fixes relay re-connection issues when the relay closes a connection +- Fixes the top padding of the quoted note in a post +- Optimizes memory use of the visual user and url tagger in new posts + +Updated translations: +- Persian by nostr:npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk +- French and English, United Kingdom by nostr:npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t +- Ukrainian by crowdin.com/profile/liizzzz +- Spanish, Spanish, Mexico and Spanish, United States by nostr:npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Arabic by nostr:npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t + +Code Quality Improvements: +- Updates to Android Studio 2023.1.1 Patch 2 + +Download: +- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-googleplay-universal-v0.83.10.apk ) +- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.83.10/amethyst-fdroid-universal-v0.83.10.apk ) +""" + + val testCase3 = """#100aDayUntil100k +Day 5 ✔️ + +Seems like they may be getting easier""" + + val testCaseJapaneseTags = + ImmutableListOfLists( + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ), + ) + val testCaseJapanese = + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow" +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt index bc32301c7..fe35c01d1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,11 +23,15 @@ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.utils.Robohash +import com.vitorpamplona.amethyst.commons.Robohash import junit.framework.TestCase.assertEquals +import okio.Buffer +import okio.buffer +import okio.source import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.nio.charset.Charset /** * Benchmark, which will execute on an Android device. @@ -41,130 +45,154 @@ class RobohashBenchmark { val warmHex = "f4f016c739b8ec0d6313540a8b12cf48a72b485d38338627ec9d427583551f9a" val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - val resultingSVG = - """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - """ - .trimIndent() + val expectedTestSVG = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" @Test fun createSVG() { @@ -172,7 +200,26 @@ class RobohashBenchmark { Robohash.assemble(warmHex, true) benchmarkRule.measureRepeated { val result = Robohash.assemble(testHex, true) - assertEquals(resultingSVG, result) + assertEquals(expectedTestSVG, result) + } + } + + @Test + fun createSVGInBufferCopy() { + // warm up + Robohash.assemble(warmHex, true) + benchmarkRule.measureRepeated { + val buffer = Buffer() + buffer.writeString(Robohash.assemble(testHex, true), Charset.defaultCharset()) + } + } + + @Test + fun createSVGInBufferViaInputStream() { + // warm up + Robohash.assemble(warmHex, true) + benchmarkRule.measureRepeated { + Robohash.assemble(testHex, true).byteInputStream(Charset.defaultCharset()).source().buffer() } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt similarity index 97% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt index eecc0213f..879200c91 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt similarity index 98% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt index b0063d14d..112132d3b 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CryptoBenchmark.kt similarity index 97% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CryptoBenchmark.kt index 82b1bbb7a..67332f084 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CryptoBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt new file mode 100644 index 000000000..b20d501f4 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt @@ -0,0 +1,192 @@ +/** + * 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.quartz.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.events.Event +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Benchmark, which will execute on an Android device. + * + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. + */ +@RunWith(AndroidJUnit4::class) +class EventBenchmark { + val payload1 = + "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\"," + + "\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\"," + + "\"created_at\":1677940007,\"content\":" + + "\"I got asked about follower count again today. Why does my follower count go down when " + + "I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nI’ll " + + "give you one final explanation to rule them all. First, let’s go over how clients calculate " + + "your follower count.\\n\\n1. Your client sends a request to all your connected relays asking " + + "for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client " + + "aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored " + + "events it will return per request. For some relays it’s 500, others 1000, some as high as 5000. " + + "Let’s say for simplicity that all your public relays use 500 as their limit. If you ask 10 " + + "relays for your followers the max possible answer you can get is 5000. That won’t change if " + + "you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but you’ll " + + "still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our " + + "limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only " + + "filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. " + + "That doesn’t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs " + + "long as you are writing to and reading from the same public relays, neither your reach nor any " + + "content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\"," + + "\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d" + + "9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" + + val payload2 = + """ + { + "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "created_at": 1683596206, + "id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", + "kind": 1, + "pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", + "sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + "tags": [ + [ + "e", + "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", + "", + "root" + ], + [ + "e", + "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", + "", + "reply" + ], + [ + "p", + "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + ], + [ + "p", + "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" + ], + [ + "p", + "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ], + "seenOn": [ + "wss://nostr.wine/" + ] +} +""" + + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test + fun parseREQString() { + benchmarkRule.measureRepeated { Event.mapper.readTree(payload1) } + } + + @Test + fun parseEvent() { + val msg = Event.mapper.readTree(payload1) + + benchmarkRule.measureRepeated { Event.fromJson(msg[2]) } + } + + @Test + fun checkSignature() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasVerifiedSignature()) + } + } + + @Test + fun checkIDHashPayload1() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) + + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasCorrectIDHash()) + } + } + + @Test + fun checkIDHashPayload2() { + val event = Event.fromJson(payload2) + + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasCorrectIDHash()) + } + } + + @Test + fun toMakeJsonForID() { + val event = Event.fromJson(payload2) + + benchmarkRule.measureRepeated { assertNotNull(event.makeJsonForId()) } + } + + @Test + fun sha256() { + val event = Event.fromJson(payload2) + val byteArray = event.makeJsonForId().toByteArray() + + benchmarkRule.measureRepeated { + // Should pass + assertNotNull(CryptoUtils.sha256(byteArray)) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt similarity index 96% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt index ecf035d6e..9bab1384e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated @@ -76,12 +76,7 @@ class GiftWrapBenchmark { expectedLength, events!! .wraps - .map { - println("TEST ${it.toJson()}") - it.toJson() - } - .joinToString("") - .length, + .sumOf { it.toJson().length }, ) // Simulate Receiver diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt similarity index 98% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 19591c4fd..54ec15353 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt similarity index 98% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt index 6363da2b9..e2e76db2f 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt similarity index 91% rename from benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index b641c7574..8a4744cde 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.benchmark +package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.encoders.HexValidator import junit.framework.TestCase.assertEquals import org.junit.Rule import org.junit.Test @@ -65,4 +66,9 @@ class HexBenchmark { benchmarkRule.measureRepeated { assertEquals(testHex, fr.acinq.secp256k1.Hex.encode(bytes)) } } + + @Test + fun isHex() { + benchmarkRule.measureRepeated { HexValidator.isHex(testHex) } + } } diff --git a/build.gradle b/build.gradle index 288b9cda8..e72a837d7 100644 --- a/build.gradle +++ b/build.gradle @@ -3,30 +3,30 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile buildscript { ext { fragment_version = "1.6.2" - lifecycle_version = '2.6.2' - compose_ui_version = '1.5.4' - nav_version = '2.7.6' + lifecycle_version = '2.7.0' + compose_ui_version = '1.6.1' + nav_version = '2.7.7' room_version = "2.4.3" - accompanist_version = '0.32.0' - coil_version = '2.5.0' - vico_version = '1.13.1' - media3_version = '1.2.0' + accompanist_version = '0.34.0' + coil_version = '2.6.0' + vico_version = '1.14.0' + media3_version = '1.2.1' core_ktx_version = '1.12.0' - material3_version = '1.1.2' + material3_version = '1.2.0' } dependencies { - classpath 'com.google.gms:google-services:4.4.0' + classpath 'com.google.gms:google-services:4.4.1' } } plugins { - id 'com.android.application' version '8.2.1' apply false - id 'com.android.library' version '8.2.1' apply false - id 'org.jetbrains.kotlin.android' version '1.9.10' apply false - id 'org.jetbrains.kotlin.jvm' version '1.9.10' apply false - id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' apply false - id 'androidx.benchmark' version '1.1.1' apply false - id 'com.diffplug.spotless' version '6.22.0' apply false + id 'com.android.application' version '8.2.2' apply false + id 'com.android.library' version '8.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false + id 'org.jetbrains.kotlin.jvm' version '1.9.22' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false + id 'androidx.benchmark' version '1.2.3' apply false + id 'com.diffplug.spotless' version '6.25.0' apply false } subprojects { diff --git a/commons/.gitignore b/commons/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/commons/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/commons/build.gradle b/commons/build.gradle new file mode 100644 index 000000000..a30b159dd --- /dev/null +++ b/commons/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.vitorpamplona.amethyst.commons' + compileSdk 34 + + defaultConfig { + minSdk 26 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + create("benchmark") { + initWith(getByName("release")) + signingConfig signingConfigs.debug + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + freeCompilerArgs += "-Xstring-concat=inline" + } +} + +dependencies { + implementation project(path: ':quartz') + + // Import @Immutable and @Stable + implementation "androidx.compose.ui:ui:$compose_ui_version" + + // immutable collections to avoid recomposition + api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7') + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} \ No newline at end of file diff --git a/commons/consumer-rules.pro b/commons/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/commons/proguard-rules.pro b/commons/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/commons/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt similarity index 98% rename from app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt rename to commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt index 1634bfb96..ed9e608da 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,13 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst +package com.vitorpamplona.amethyst.commons import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.service.RichTextParser -import com.vitorpamplona.amethyst.service.RichTextViewerState import com.vitorpamplona.quartz.events.EmptyTagList -import org.junit.Assert +import com.vitorpamplona.quartz.events.ImmutableListOfLists import org.junit.Test import org.junit.runner.RunWith @@ -688,8 +686,10 @@ class RichTextParserTest { @Test fun testTextToParse() { - val state = RichTextParser().parseText(textToParse, EmptyTagList) - Assert.assertEquals( + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText(textToParse, EmptyTagList) + org.junit.Assert.assertEquals( "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl", state.urlSet.joinToString(", "), ) @@ -4021,26 +4021,28 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - Assert.assertEquals( + org.junit.Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) } - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals(651, state.paragraphs.size) + org.junit.Assert.assertTrue(state.imagesForPager.isEmpty()) + org.junit.Assert.assertTrue(state.imageList.isEmpty()) + org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) + org.junit.Assert.assertEquals(651, state.paragraphs.size) } @Test fun testShortTextToParse() { - val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList) - Assert.assertTrue(state.urlSet.isEmpty()) - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals( + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText("Hi, how are you doing? ", EmptyTagList) + org.junit.Assert.assertTrue(state.urlSet.isEmpty()) + org.junit.Assert.assertTrue(state.imagesForPager.isEmpty()) + org.junit.Assert.assertTrue(state.imageList.isEmpty()) + org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) + org.junit.Assert.assertEquals( "Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText, ) @@ -4048,12 +4050,14 @@ class RichTextParserTest { @Test fun testShortNewLinesTextToParse() { - val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList) - Assert.assertTrue(state.urlSet.isEmpty()) - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals( + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList) + org.junit.Assert.assertTrue(state.urlSet.isEmpty()) + org.junit.Assert.assertTrue(state.imagesForPager.isEmpty()) + org.junit.Assert.assertTrue(state.imageList.isEmpty()) + org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) + org.junit.Assert.assertEquals( "\nHi, \nhow\n\n\n are you doing? \n", state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, ) @@ -4071,17 +4075,22 @@ class RichTextParserTest { """ .trimIndent() - val state = RichTextParser().parseText(text, EmptyTagList) - Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull()) - Assert.assertEquals( + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText(text, EmptyTagList) + org.junit.Assert.assertEquals( + "https://lnshort.it/live-stream-embeds/", + state.urlSet.firstOrNull(), + ) + org.junit.Assert.assertEquals( "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull(), ) - Assert.assertEquals( + org.junit.Assert.assertEquals( "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url, ) - Assert.assertTrue(state.customEmoji.isEmpty()) + org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) printStateForDebug(state) @@ -4131,7 +4140,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - Assert.assertEquals( + org.junit.Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4143,7 +4152,9 @@ class RichTextParserTest { val text = "That’s it ! That’s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg " - val state = RichTextParser().parseText(text, EmptyTagList) + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText(text, EmptyTagList) printStateForDebug(state) @@ -4162,7 +4173,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - Assert.assertEquals( + org.junit.Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4174,7 +4185,9 @@ class RichTextParserTest { val text = "That’s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg That’s the #note" - val state = RichTextParser().parseText(text, EmptyTagList) + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText(text, EmptyTagList) printStateForDebug(state) @@ -4192,7 +4205,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - Assert.assertEquals( + org.junit.Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4203,7 +4216,9 @@ class RichTextParserTest { fun testUrlsEndingInPeriod() { val text = "That’s it! http://vitorpamplona.com/. That’s the note" - val state = RichTextParser().parseText(text, EmptyTagList) + val state = + com.vitorpamplona.amethyst.commons.RichTextParser() + .parseText(text, EmptyTagList) printStateForDebug(state) @@ -4221,14 +4236,52 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - Assert.assertEquals( + org.junit.Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } + + @Test + fun testJapaneseWithEmojis() { + val tags = + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ) + val text = + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow" + + val state = + RichTextParser().parseText(text, ImmutableListOfLists(tags)) + + printStateForDebug(state) + + val expectedResult = + listOf( + "Emoji(\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B)", + "HashTag(#ioメシヨソイゲーム)", + "Link(https://misskey.io/play/9g3qza4jow)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + org.junit.Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) } } - private fun printStateForDebug(state: RichTextViewerState) { + private fun printStateForDebug(state: com.vitorpamplona.amethyst.commons.RichTextViewerState) { state.paragraphs.forEach { paragraph -> paragraph.words.forEach { seg -> println( diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/TextFieldValueExtensionTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/TextFieldValueExtensionTest.kt new file mode 100644 index 000000000..1c8174eab --- /dev/null +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/TextFieldValueExtensionTest.kt @@ -0,0 +1,121 @@ +/** + * 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.commons + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TextFieldValueExtensionTest { + @Test + fun testInsertTwoCharsStart() { + val current = TextFieldValue("ab", selection = TextRange(0, 0)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("http://a.b ab", next.text) + assertEquals(TextRange(10, 10), next.selection) + } + + @Test + fun testInsertTwoCharsMiddle() { + val current = TextFieldValue("ab", selection = TextRange(1, 1)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b b", next.text) + assertEquals(TextRange(12, 12), next.selection) + } + + @Test + fun testInsertTwoCharsEnd() { + val current = TextFieldValue("ab", selection = TextRange(2, 2)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("ab http://a.b", next.text) + assertEquals(TextRange(13, 13), next.selection) + } + + @Test + fun testInsertOneCharStart() { + val current = TextFieldValue("a", selection = TextRange(0, 0)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("http://a.b a", next.text) + assertEquals(TextRange(10, 10), next.selection) + } + + @Test + fun testInsertOneCharEnd() { + val current = TextFieldValue("a", selection = TextRange(1, 1)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b", next.text) + assertEquals(TextRange(12, 12), next.selection) + } + + @Test + fun testInsertTwoCharsWithSpaceStart() { + val current = TextFieldValue("a b", selection = TextRange(1, 1)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b b", next.text) + assertEquals(TextRange(12, 12), next.selection) + } + + @Test + fun testInsertTwoCharsWithSpaceEnd() { + val current = TextFieldValue("a b", selection = TextRange(2, 2)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b b", next.text) + assertEquals(TextRange(12, 12), next.selection) + } + + @Test + fun testInsertTwoCharsWithThreeSpaceStart() { + val current = TextFieldValue("a b", selection = TextRange(1, 1)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b b", next.text) + assertEquals(TextRange(12, 12), next.selection) + } + + @Test + fun testInsertTwoCharsWithThreeSpaceMiddle() { + val current = TextFieldValue("a b", selection = TextRange(2, 2)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b b", next.text) + assertEquals(TextRange(12, 12), next.selection) + } + + @Test + fun testInsertTwoCharsWithThreeSpaceEnd() { + val current = TextFieldValue("a b", selection = TextRange(3, 3)) + val next = current.insertUrlAtCursor("http://a.b") + + assertEquals("a http://a.b b", next.text) + assertEquals(TextRange(13, 13), next.selection) + } +} diff --git a/commons/src/main/AndroidManifest.xml b/commons/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/commons/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/ExpandableTextCutOffCalculator.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/ExpandableTextCutOffCalculator.kt new file mode 100644 index 000000000..d38dc799b --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/ExpandableTextCutOffCalculator.kt @@ -0,0 +1,57 @@ +/** + * 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.commons + +class ExpandableTextCutOffCalculator { + companion object { + private const val SHORT_TEXT_LENGTH = 350 + private const val SHORTEN_AFTER_LINES = 10 + + fun indexToCutOff(content: String): Int { + // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters + val firstSpaceAfterCut = + content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val firstNewLineAfterCut = + content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val firstLineAfterLineLimits = + content.nthIndexOf('\n', SHORTEN_AFTER_LINES).let { if (it < 0) content.length else it } + + return minOf(firstSpaceAfterCut, firstNewLineAfterCut, firstLineAfterLineLimits) + } + } +} + +fun String.nthIndexOf( + ch: Char, + N: Int, +): Int { + var occur = N + var pos = -1 + + while (occur > 0) { + // calling the native function multiple times is faster than looping just once + pos = indexOf(ch, pos + 1) + if (pos == -1) return -1 + occur-- + } + + return if (occur == 0) pos else -1 +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/MediaContentModels.kt new file mode 100644 index 000000000..107e02051 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/MediaContentModels.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons + +import androidx.compose.runtime.Immutable +import java.io.File + +@Immutable +abstract class BaseMediaContent( + val description: String? = null, + val dim: String? = null, + val blurhash: String? = null, +) + +@Immutable +abstract class MediaUrlContent( + val url: String, + description: String? = null, + val hash: String? = null, + dim: String? = null, + blurhash: String? = null, + val uri: String? = null, +) : BaseMediaContent(description, dim, blurhash) + +@Immutable +class MediaUrlImage( + url: String, + description: String? = null, + hash: String? = null, + blurhash: String? = null, + dim: String? = null, + uri: String? = null, + val contentWarning: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri) + +@Immutable +class MediaUrlVideo( + url: String, + description: String? = null, + hash: String? = null, + dim: String? = null, + uri: String? = null, + val artworkUri: String? = null, + val authorName: String? = null, + blurhash: String? = null, + val contentWarning: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri) + +@Immutable +abstract class MediaPreloadedContent( + val localFile: File?, + description: String? = null, + val mimeType: String? = null, + val isVerified: Boolean? = null, + dim: String? = null, + blurhash: String? = null, + val uri: String, +) : BaseMediaContent(description, dim, blurhash) { + fun localFileExists() = localFile != null && localFile.exists() +} + +@Immutable +class MediaLocalImage( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: String? = null, + blurhash: String? = null, + isVerified: Boolean? = null, + uri: String, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri) + +@Immutable +class MediaLocalVideo( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: String? = null, + blurhash: String? = null, + isVerified: Boolean? = null, + uri: String, + val artworkUri: String? = null, + val authorName: String? = null, +) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt new file mode 100644 index 000000000..bb205711b --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt @@ -0,0 +1,396 @@ +/** + * 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.commons + +import android.util.Log +import android.util.Patterns +import com.linkedin.urls.detection.UrlDetector +import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji +import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.collections.immutable.toPersistentList +import java.net.MalformedURLException +import java.net.URISyntaxException +import java.net.URL +import java.util.regex.Pattern +import kotlin.coroutines.cancellation.CancellationException + +class RichTextParser() { + fun parseMediaUrl( + fullUrl: String, + eventTags: ImmutableListOfLists, + ): MediaUrlContent? { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip54InlineMetadata().parse(fullUrl) + val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists) + + MediaUrlImage( + url = fullUrl, + description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], + blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], + dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + contentWarning = frags["content-warning"] ?: tags["content-warning"], + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip54InlineMetadata().parse(fullUrl) + val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists) + MediaUrlVideo( + url = fullUrl, + description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], + blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], + dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + contentWarning = frags["content-warning"] ?: tags["content-warning"], + ) + } else { + null + } + } + + fun parseValidUrls(content: String): LinkedHashSet { + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + + return urls.mapNotNullTo(LinkedHashSet(urls.size)) { + if (it.originalUrl.contains("@")) { + if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { + null + } else { + it.originalUrl + } + } else if (isNumber(it.originalUrl)) { + null // avoids urls that look like 123.22 + } else if (it.originalUrl.contains("。")) { + null // avoids Japanese characters as fake urls + } else { + if (HTTPRegex.matches(it.originalUrl)) { + it.originalUrl + } else { + null + } + } + } + } + + fun parseText( + content: String, + tags: ImmutableListOfLists, + ): RichTextViewerState { + val urlSet = parseValidUrls(content) + + val imagesForPager = + urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url } + val imageList = imagesForPager.values.toList() + + val emojiMap = Nip30CustomEmoji.createEmojiMap(tags) + + 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, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): ImmutableList { + val lines = content.split('\n') + val paragraphSegments = ArrayList(lines.size) + + lines.forEach { paragraph -> + var isDirty = false + val isRTL = isArabic(paragraph) + + val wordList = paragraph.trimEnd().split(' ') + val segments = ArrayList(wordList.size) + wordList.forEach { word -> + val wordSegment = wordIdentifier(word, images, urls, emojis, tags) + if (wordSegment !is RegularTextSegment) { + isDirty = true + } + segments.add(wordSegment) + } + + val newSegments = + if (isDirty) { + ParagraphState(segments.toPersistentList(), isRTL) + } else { + ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) + } + + paragraphSegments.add(newSegments) + } + + return paragraphSegments.toImmutableList() + } + + private fun isNumber(word: String) = numberPattern.matcher(word).matches() + + private fun isPhoneNumberChar(c: Char): Boolean { + return when (c) { + in '0'..'9' -> true + '-' -> true + ' ' -> true + '.' -> true + else -> false + } + } + + fun isPotentialPhoneNumber(word: String): Boolean { + if (word.length !in 7..14) return false + var isPotentialNumber = true + + for (c in word) { + if (!isPhoneNumberChar(c)) { + isPotentialNumber = false + break + } + } + return isPotentialNumber + } + + 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, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): Segment { + if (word.isEmpty()) return RegularTextSegment(word) + + if (images.contains(word)) return ImageSegment(word) + + if (urls.contains(word)) return LinkSegment(word) + + if (Nip30CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) + + if (word.startsWith("lnbc", true)) return InvoiceSegment(word) + + if (word.startsWith("lnurl", true)) return WithdrawSegment(word) + + if (word.startsWith("cashuA", true)) return CashuSegment(word) + + if (startsWithNIP19Scheme(word)) return BechSegment(word) + + if (word.startsWith("#")) return parseHash(word, tags) + + if (word.contains("@")) { + if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) return EmailSegment(word) + } + + if (isPotentialPhoneNumber(word) && !isDate(word)) { + if (Patterns.PHONE.matcher(word).matches()) return PhoneSegment(word) + } + + val indexOfPeriod = word.indexOf(".") + if (indexOfPeriod > 0 && indexOfPeriod < word.length - 1) { // periods cannot be the last one + val schemelessMatcher = noProtocolUrlValidator.matcher(word) + if (schemelessMatcher.find()) { + val url = schemelessMatcher.group(1) // url + val additionalChars = schemelessMatcher.group(4).ifEmpty { null } // additional chars + val pattern = + """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" + .toRegex(RegexOption.IGNORE_CASE) + if (pattern.find(word) != null && url != null) { + return SchemelessUrlSegment(word, url, additionalChars) + } + } + } + + return RegularTextSegment(word) + } + + private fun parseHash( + word: String, + tags: ImmutableListOfLists, + ): 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) { + if (e is CancellationException) throw e + 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).ifEmpty { null }) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + 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%]*)$") + + // 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) + + val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") + val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") + + val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") + val hashTagsPattern: Pattern = + Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) + + val acceptedNIP19schemes = + listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed") + + listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed").map { + it.uppercase() + } + + private fun removeQueryParamsForExtensionComparison(fullUrl: String): String { + return if (fullUrl.contains("?")) { + fullUrl.split("?")[0].lowercase() + } else if (fullUrl.contains("#")) { + fullUrl.split("#")[0].lowercase() + } else { + fullUrl.lowercase() + } + } + + fun isImageOrVideoUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + + return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || + videoExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + fun isImageUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return imageExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + fun isVideoUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return videoExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + fun isValidURL(url: String?): Boolean { + return try { + URL(url).toURI() + true + } catch (e: MalformedURLException) { + false + } catch (e: URISyntaxException) { + false + } + } + + fun parseImageOrVideo(fullUrl: String): BaseMediaContent { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + return if (isImage) { + MediaUrlImage(fullUrl) + } else if (isVideo) { + MediaUrlVideo(fullUrl) + } else { + MediaUrlImage(fullUrl) + } + } + + fun startsWithNIP19Scheme(word: String): Boolean { + if (word.isEmpty()) return false + return if (word[0] == 'n' || word[0] == 'N') { + if (word.startsWith("nostr:n") || word.startsWith("NOSTR:N")) { + acceptedNIP19schemes.any { word.startsWith(it, 6) } + } else { + acceptedNIP19schemes.any { word.startsWith(it) } + } + } else if (word[0] == '@') { + acceptedNIP19schemes.any { word.startsWith(it, 1) } + } else { + false + } + } + + fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matcher(url).matches() + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt new file mode 100644 index 000000000..1db5964a5 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt @@ -0,0 +1,90 @@ +/** + * 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.commons + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet + +@Immutable +data class RichTextViewerState( + val urlSet: ImmutableSet, + val imagesForPager: ImmutableMap, + val imageList: ImmutableList, + val customEmoji: ImmutableMap, + val paragraphs: ImmutableList, +) + +@Immutable +data class ParagraphState(val words: ImmutableList, val isRTL: Boolean) + +@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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/Robohash.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt rename to commons/src/main/java/com/vitorpamplona/amethyst/commons/Robohash.kt index 28fa3c3e2..92b741c54 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/Robohash.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,10 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.amethyst.commons import android.util.Log -import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexValidator @@ -55,9 +54,7 @@ object Robohash { private fun reduce( start: Int, channel: Byte, - ): Int { - return (start + (channel.toUByte().toInt() * 0.3906f)).toInt() - } + ) = (start + (channel.toUByte().toInt() * 0.3906f)).toInt() private fun bytesToRGB( r: Byte, @@ -85,6 +82,7 @@ object Robohash { Log.w("Robohash", "$msg is not a hex") CryptoUtils.sha256(msg.toByteArray()) } + val bgColor = bytesToRGB(hash[0], hash[1], hash[2], isLightTheme) val fgColor = bytesToRGB(hash[3], hash[4], hash[5], !isLightTheme) val body = bodies[byteMod10(hash[6])] @@ -110,41 +108,42 @@ object Robohash { BACKGROUND.length + END.length - val result = StringBuilder(capacity) + val result = + buildString(capacity) { + append(HEADER) - result.append(HEADER) + append(".cls-bg{fill:") + append(bgColor) + append(";}.cls-fill-1{fill:") + append(fgColor) + append(";}.cls-fill-2{fill:") + append(fgColor) + append(";}") - result.append(".cls-bg{fill:") - result.append(bgColor) - result.append(";}.cls-fill-1{fill:") - result.append(fgColor) - result.append(";}.cls-fill-2{fill:") - result.append(fgColor) - result.append(";}") + append(body.style) + append(face.style) + append(eye.style) + append(mouth.style) + append(accessory.style) - result.append(body.style) - result.append(face.style) - result.append(eye.style) - result.append(mouth.style) - result.append(accessory.style) + append(MID) - result.append(MID) + append(BACKGROUND) + append(body.paths) + append(face.paths) + append(eye.paths) + append(mouth.paths) + append(accessory.paths) - result.append(BACKGROUND) - result.append(body.paths) - result.append(face.paths) - result.append(eye.paths) - result.append(mouth.paths) - result.append(accessory.paths) + append(END) + } - result.append(END) + check(result.length == capacity) { "${result.length} was different from $capacity" } - val resultStr = result.toString() - check(resultStr.length == capacity) { "${resultStr.length} was different from $capacity" } - return resultStr + return result } - @Immutable private data class Part(val style: String, val paths: String) + private data class Part(val style: String, val paths: String) const val HEADER = "
+ * key derivation function. This class will attempt to load a native library + * containing the optimized C implementation from + * http://www.tarsnap.com/scrypt.html and + * fall back to the pure Java version if that fails. + * + * @author Will Glozer + */ +public class SCrypt { + + /** + * Implementation of the scrypt KDF. + * + * @param passwd Password. + * @param salt Salt. + * @param N CPU cost parameter. + * @param r Memory cost parameter. + * @param p Parallelization parameter. + * @param dkLen Intended length of the derived key. + * + * @return The derived key. + * + * @throws GeneralSecurityException when HMAC_SHA256 is not available. + */ + public static byte[] scrypt(byte[] passwd, byte[] salt, int N, int r, int p, int dkLen) throws GeneralSecurityException { + return scryptJ(passwd, salt, N, r, p, dkLen); + } + + /** + * Pure Java implementation of the scrypt KDF. + * + * @param passwd Password. + * @param salt Salt. + * @param N CPU cost parameter. + * @param r Memory cost parameter. + * @param p Parallelization parameter. + * @param dkLen Intended length of the derived key. + * + * @return The derived key. + * + * @throws GeneralSecurityException when HMAC_SHA256 is not available. + */ + public static byte[] scryptJ(byte[] passwd, byte[] salt, int N, int r, int p, int dkLen) throws GeneralSecurityException { + if (N < 2 || (N & (N - 1)) != 0) throw new IllegalArgumentException("N must be a power of 2 greater than 1"); + + if (N > MAX_VALUE / 128 / r) throw new IllegalArgumentException("Parameter N is too large"); + if (r > MAX_VALUE / 128 / p) throw new IllegalArgumentException("Parameter r is too large"); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeyOrEmptySpec(passwd, "HmacSHA256")); + + byte[] DK = new byte[dkLen]; + + byte[] B = new byte[128 * r * p]; + byte[] XY = new byte[256 * r]; + byte[] V = new byte[128 * r * N]; + int i; + + PBKDF.pbkdf2(mac, salt, 1, B, p * 128 * r); + + for (i = 0; i < p; i++) { + smix(B, i * 128 * r, r, N, V, XY); + } + + PBKDF.pbkdf2(mac, B, 1, DK, dkLen); + + return DK; + } + + public static void smix(byte[] B, int Bi, int r, int N, byte[] V, byte[] XY) { + int Xi = 0; + int Yi = 128 * r; + int i; + + arraycopy(B, Bi, XY, Xi, 128 * r); + + for (i = 0; i < N; i++) { + arraycopy(XY, Xi, V, i * (128 * r), 128 * r); + blockmix_salsa8(XY, Xi, Yi, r); + } + + for (i = 0; i < N; i++) { + int j = integerify(XY, Xi, r) & (N - 1); + blockxor(V, j * (128 * r), XY, Xi, 128 * r); + blockmix_salsa8(XY, Xi, Yi, r); + } + + arraycopy(XY, Xi, B, Bi, 128 * r); + } + + public static void blockmix_salsa8(byte[] BY, int Bi, int Yi, int r) { + byte[] X = new byte[64]; + int i; + + arraycopy(BY, Bi + (2 * r - 1) * 64, X, 0, 64); + + for (i = 0; i < 2 * r; i++) { + blockxor(BY, i * 64, X, 0, 64); + salsa20_8(X); + arraycopy(X, 0, BY, Yi + (i * 64), 64); + } + + for (i = 0; i < r; i++) { + arraycopy(BY, Yi + (i * 2) * 64, BY, Bi + (i * 64), 64); + } + + for (i = 0; i < r; i++) { + arraycopy(BY, Yi + (i * 2 + 1) * 64, BY, Bi + (i + r) * 64, 64); + } + } + + public static int R(int a, int b) { + return (a << b) | (a >>> (32 - b)); + } + + public static void salsa20_8(byte[] B) { + int[] B32 = new int[16]; + int[] x = new int[16]; + int i; + + for (i = 0; i < 16; i++) { + B32[i] = (B[i * 4 + 0] & 0xff) << 0; + B32[i] |= (B[i * 4 + 1] & 0xff) << 8; + B32[i] |= (B[i * 4 + 2] & 0xff) << 16; + B32[i] |= (B[i * 4 + 3] & 0xff) << 24; + } + + arraycopy(B32, 0, x, 0, 16); + + for (i = 8; i > 0; i -= 2) { + x[ 4] ^= R(x[ 0]+x[12], 7); x[ 8] ^= R(x[ 4]+x[ 0], 9); + x[12] ^= R(x[ 8]+x[ 4],13); x[ 0] ^= R(x[12]+x[ 8],18); + x[ 9] ^= R(x[ 5]+x[ 1], 7); x[13] ^= R(x[ 9]+x[ 5], 9); + x[ 1] ^= R(x[13]+x[ 9],13); x[ 5] ^= R(x[ 1]+x[13],18); + x[14] ^= R(x[10]+x[ 6], 7); x[ 2] ^= R(x[14]+x[10], 9); + x[ 6] ^= R(x[ 2]+x[14],13); x[10] ^= R(x[ 6]+x[ 2],18); + x[ 3] ^= R(x[15]+x[11], 7); x[ 7] ^= R(x[ 3]+x[15], 9); + x[11] ^= R(x[ 7]+x[ 3],13); x[15] ^= R(x[11]+x[ 7],18); + x[ 1] ^= R(x[ 0]+x[ 3], 7); x[ 2] ^= R(x[ 1]+x[ 0], 9); + x[ 3] ^= R(x[ 2]+x[ 1],13); x[ 0] ^= R(x[ 3]+x[ 2],18); + x[ 6] ^= R(x[ 5]+x[ 4], 7); x[ 7] ^= R(x[ 6]+x[ 5], 9); + x[ 4] ^= R(x[ 7]+x[ 6],13); x[ 5] ^= R(x[ 4]+x[ 7],18); + x[11] ^= R(x[10]+x[ 9], 7); x[ 8] ^= R(x[11]+x[10], 9); + x[ 9] ^= R(x[ 8]+x[11],13); x[10] ^= R(x[ 9]+x[ 8],18); + x[12] ^= R(x[15]+x[14], 7); x[13] ^= R(x[12]+x[15], 9); + x[14] ^= R(x[13]+x[12],13); x[15] ^= R(x[14]+x[13],18); + } + + for (i = 0; i < 16; ++i) B32[i] = x[i] + B32[i]; + + for (i = 0; i < 16; i++) { + B[i * 4 + 0] = (byte) (B32[i] >> 0 & 0xff); + B[i * 4 + 1] = (byte) (B32[i] >> 8 & 0xff); + B[i * 4 + 2] = (byte) (B32[i] >> 16 & 0xff); + B[i * 4 + 3] = (byte) (B32[i] >> 24 & 0xff); + } + } + + public static void blockxor(byte[] S, int Si, byte[] D, int Di, int len) { + for (int i = 0; i < len; i++) { + D[Di + i] ^= S[Si + i]; + } + } + + public static int integerify(byte[] B, int Bi, int r) { + int n; + + Bi += (2 * r - 1) * 64; + + n = (B[Bi + 0] & 0xff) << 0; + n |= (B[Bi + 1] & 0xff) << 8; + n |= (B[Bi + 2] & 0xff) << 16; + n |= (B[Bi + 3] & 0xff) << 24; + + return n; + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SecretKeyOrEmptySpec.java b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SecretKeyOrEmptySpec.java new file mode 100644 index 000000000..7e3766bdc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SecretKeyOrEmptySpec.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 1998, 2015, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.vitorpamplona.quartz.crypto; + +import java.security.MessageDigest; +import java.security.spec.KeySpec; +import java.util.Locale; +import javax.crypto.SecretKey; + +/** + * This class specifies a secret key in a provider-independent fashion. + * + *

It can be used to construct a SecretKey from a byte array, + * without having to go through a (provider-based) + * SecretKeyFactory. + * + *

This class is only useful for raw secret keys that can be represented as + * a byte array and have no key parameters associated with them, e.g., DES or + * Triple DES keys. + * + * @author Jan Luehe + * + * @see javax.crypto.SecretKey + * @see javax.crypto.SecretKeyFactory + * @since 1.4 + */ +public class SecretKeyOrEmptySpec implements KeySpec, SecretKey { + + private static final long serialVersionUID = 6577238317307289933L; + + /** + * The secret key. + * + * @serial + */ + private byte[] key; + + /** + * The name of the algorithm associated with this key. + * + * @serial + */ + private String algorithm; + + /** + * Constructs a secret key from the given byte array. + * + *

This constructor does not check if the given bytes indeed specify a + * secret key of the specified algorithm. For example, if the algorithm is + * DES, this constructor does not check if key is 8 bytes + * long, and also does not check for weak or semi-weak keys. + * In order for those checks to be performed, an algorithm-specific + * key specification class (in this case: + * {@link DESKeySpec DESKeySpec}) + * should be used. + * + * @param key the key material of the secret key. The contents of + * the array are copied to protect against subsequent modification. + * @param algorithm the name of the secret-key algorithm to be associated + * with the given key material. + * See Appendix A in the + * Java Cryptography Architecture Reference Guide + * for information about standard algorithm names. + * @exception IllegalArgumentException if algorithm + * is null or key is null or empty. + */ + public SecretKeyOrEmptySpec(byte[] key, String algorithm) { + if (key == null || algorithm == null) { + throw new IllegalArgumentException("Missing argument"); + } + this.key = key.clone(); + this.algorithm = algorithm; + } + + /** + * Constructs a secret key from the given byte array, using the first + * len bytes of key, starting at + * offset inclusive. + * + *

The bytes that constitute the secret key are + * those between key[offset] and + * key[offset+len-1] inclusive. + * + *

This constructor does not check if the given bytes indeed specify a + * secret key of the specified algorithm. For example, if the algorithm is + * DES, this constructor does not check if key is 8 bytes + * long, and also does not check for weak or semi-weak keys. + * In order for those checks to be performed, an algorithm-specific key + * specification class (in this case: + * {@link DESKeySpec DESKeySpec}) + * must be used. + * + * @param key the key material of the secret key. The first + * len bytes of the array beginning at + * offset inclusive are copied to protect + * against subsequent modification. + * @param offset the offset in key where the key material + * starts. + * @param len the length of the key material. + * @param algorithm the name of the secret-key algorithm to be associated + * with the given key material. + * See Appendix A in the + * Java Cryptography Architecture Reference Guide + * for information about standard algorithm names. + * @exception IllegalArgumentException if algorithm + * is null or key is null, empty, or too short, + * i.e. {@code key.length-offsetoffset or len index bytes outside the + * key. + */ + public SecretKeyOrEmptySpec(byte[] key, int offset, int len, String algorithm) { + if (key == null || algorithm == null) { + throw new IllegalArgumentException("Missing argument"); + } + if (key.length-offset < len) { + throw new IllegalArgumentException + ("Invalid offset/length combination"); + } + if (len < 0) { + throw new ArrayIndexOutOfBoundsException("len is negative"); + } + this.key = new byte[len]; + System.arraycopy(key, offset, this.key, 0, len); + this.algorithm = algorithm; + } + + /** + * Returns the name of the algorithm associated with this secret key. + * + * @return the secret key algorithm. + */ + public String getAlgorithm() { + return this.algorithm; + } + + /** + * Returns the name of the encoding format for this secret key. + * + * @return the string "RAW". + */ + public String getFormat() { + return "RAW"; + } + + /** + * Returns the key material of this secret key. + * + * @return the key material. Returns a new array + * each time this method is called. + */ + public byte[] getEncoded() { + return this.key.clone(); + } + + /** + * Calculates a hash code value for the object. + * Objects that are equal will also have the same hashcode. + */ + public int hashCode() { + int retval = 0; + for (int i = 1; i < this.key.length; i++) { + retval += this.key[i] * i; + } + if (this.algorithm.equalsIgnoreCase("TripleDES")) + return (retval ^= "desede".hashCode()); + else + return (retval ^= + this.algorithm.toLowerCase(Locale.ENGLISH).hashCode()); + } + + /** + * Tests for equality between the specified object and this + * object. Two SecretKeySpec objects are considered equal if + * they are both SecretKey instances which have the + * same case-insensitive algorithm name and key encoding. + * + * @param obj the object to test for equality with this object. + * + * @return true if the objects are considered equal, false if + * obj is null or otherwise. + */ + public boolean equals(Object obj) { + if (this == obj) + return true; + + if (!(obj instanceof SecretKey)) + return false; + + String thatAlg = ((SecretKey)obj).getAlgorithm(); + if (!(thatAlg.equalsIgnoreCase(this.algorithm))) { + if ((!(thatAlg.equalsIgnoreCase("DESede")) + || !(this.algorithm.equalsIgnoreCase("TripleDES"))) + && (!(thatAlg.equalsIgnoreCase("TripleDES")) + || !(this.algorithm.equalsIgnoreCase("DESede")))) + return false; + } + + byte[] thatKey = ((SecretKey)obj).getEncoded(); + + return MessageDigest.isEqual(this.key, thatKey); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt index dd39dec2a..766841128 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt index 3025b4aea..8d5c748a0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt index 9b2cec4fa..c04e664db 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -30,10 +30,10 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela fun toNAddr(): String { return TlvBuilder() .apply { - addString(Nip19.TlvTypes.SPECIAL, dTag) - addStringIfNotNull(Nip19.TlvTypes.RELAY, relay) - addHex(Nip19.TlvTypes.AUTHOR, pubKeyHex) - addInt(Nip19.TlvTypes.KIND, kind) + addString(Nip19Bech32.TlvTypes.SPECIAL, dTag) + addStringIfNotNull(Nip19Bech32.TlvTypes.RELAY, relay) + addHex(Nip19Bech32.TlvTypes.AUTHOR, pubKeyHex) + addInt(Nip19Bech32.TlvTypes.KIND, kind) } .build() .toNAddress() @@ -76,10 +76,10 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela if (key.startsWith("naddr")) { val tlv = Tlv.parse(key.bechToBytes()) - val d = tlv.firstAsString(Nip19.TlvTypes.SPECIAL) ?: "" - val relay = tlv.firstAsString(Nip19.TlvTypes.RELAY) - val author = tlv.firstAsHex(Nip19.TlvTypes.AUTHOR) - val kind = tlv.firstAsInt(Nip19.TlvTypes.KIND) + val d = tlv.firstAsString(Nip19Bech32.TlvTypes.SPECIAL) ?: "" + val relay = tlv.firstAsString(Nip19Bech32.TlvTypes.RELAY) + val author = tlv.firstAsHex(Nip19Bech32.TlvTypes.AUTHOR) + val kind = tlv.firstAsInt(Nip19Bech32.TlvTypes.KIND) if (kind != null && author != null) { return ATag(kind, author, d, relay) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt index fa2bd194c..f3ce3903c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -51,7 +51,7 @@ object Bech32 { const val ALPHABET: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" const val ALPHABET_UPPERCASE: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" - enum class Encoding(public val constant: Int) { + enum class Encoding(val constant: Int) { Bech32(1), Bech32m(0x2bc830a3), Beck32WithoutChecksum(0), @@ -271,18 +271,6 @@ object Bech32 { } } -fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding.Bech32) - -fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32) - -fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32) - -fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32) - -fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32) - -fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32) - fun String.bechToBytes(hrp: String? = null): ByteArray { val decodedForm = Bech32.decodeBytes(this) hrp?.also { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt index a410453a8..ec7025ad5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -34,29 +34,9 @@ fun HexKey.hexToByteArray(): ByteArray { object HexValidator { private fun isHexChar(c: Char): Boolean { return when (c) { - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - -> true + in '0'..'9' -> true + in 'a'..'f' -> true + in 'A'..'F' -> true else -> false } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt index 986020854..6fadab056 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt index 0d88d2d41..e56cc00bf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt index f19556812..d4d2bb178 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,19 +23,24 @@ package com.vitorpamplona.quartz.encoders import android.util.Log import java.util.regex.Pattern -val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") - class Lud06 { + companion object { + val LNURLP_PATTERN = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") + } + fun toLud16(str: String): String? { return try { val url = toLnUrlp(str) - val matcher = lnurlpPattern.matcher(url) - matcher.find() - val domain = matcher.group(2) - val username = matcher.group(3) + val matcher = LNURLP_PATTERN.matcher(url) + if (matcher.find()) { + val domain = matcher.group(2) + val username = matcher.group(3) - "$username@$domain" + "$username@$domain" + } else { + null + } } catch (t: Throwable) { t.printStackTrace() Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt similarity index 82% rename from app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt index a07b807f5..58f43b9dc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip11RelayInformation.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,17 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model +package com.vitorpamplona.quartz.encoders import androidx.compose.runtime.Stable import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @Stable -class RelayInformation( +class Nip11RelayInformation( val id: String?, val name: String?, val description: String?, + val icon: String?, val pubkey: String?, val contact: String?, val supported_nips: List?, @@ -41,13 +42,15 @@ class RelayInformation( val tags: List?, val posting_policy: String?, val payments_url: String?, + val retention: List?, val fees: RelayInformationFees?, + val nip50: List?, ) { companion object { val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java) + fun fromJson(json: String): Nip11RelayInformation = mapper.readValue(json, Nip11RelayInformation::class.java) } } @@ -63,7 +66,6 @@ class RelayInformationFees( val admission: List?, val subscription: List?, val publication: List?, - val retention: List?, ) class RelayInformationLimitation( @@ -78,4 +80,13 @@ class RelayInformationLimitation( val min_pow_difficulty: Int?, val auth_required: Boolean?, val payment_required: Boolean?, + val restricted_writes: Boolean?, + val created_at_lower_limit: Int?, + val created_at_upper_limit: Int?, +) + +class RelayInformationRetentionData( + val kinds: ArrayList, + val tiem: Int?, + val count: Int?, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt deleted file mode 100644 index 7e1d2418e..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt +++ /dev/null @@ -1,234 +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.quartz.encoders - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.crypto.KeyPair -import java.util.regex.Pattern - -object Nip19 { - enum class Type { - USER, - NOTE, - EVENT, - RELAY, - ADDRESS, - } - - enum class TlvTypes(val id: Byte) { - SPECIAL(0), - RELAY(1), - AUTHOR(2), - KIND(3), - } - - val nip19regex = - Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", - Pattern.CASE_INSENSITIVE, - ) - - @Immutable - data class Return( - val type: Type, - val hex: String, - val relay: String? = null, - val author: String? = null, - val kind: Int? = null, - val additionalChars: String = "", - ) - - fun uriToRoute(uri: String?): Return? { - if (uri == null) return null - - try { - val matcher = nip19regex.matcher(uri) - if (!matcher.find()) { - return null - } - - val uriScheme = matcher.group(1) // nostr: - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) // additional chars - - return parseComponents(uriScheme, type, key, additionalChars) - } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) - } - - return null - } - - fun parseComponents( - uriScheme: String?, - type: String, - key: String?, - additionalChars: String?, - ): Return? { - return try { - val bytes = (type + key).bechToBytes() - val parsed = - when (type.lowercase()) { - "npub1" -> npub(bytes) - "note1" -> note(bytes) - "nprofile1" -> nprofile(bytes) - "nevent1" -> nevent(bytes) - "nrelay1" -> nrelay(bytes) - "naddr1" -> naddr(bytes) - else -> null - } - parsed?.copy(additionalChars = additionalChars ?: "") - } catch (e: Throwable) { - Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) - null - } - } - - private fun npub(bytes: ByteArray): Return { - return Return(Type.USER, bytes.toHexKey()) - } - - private fun note(bytes: ByteArray): Return { - return Return(Type.NOTE, bytes.toHexKey()) - } - - private fun nprofile(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) - - val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null - val relay = tlv.firstAsString(TlvTypes.RELAY) - - return Return(Type.USER, hex, relay) - } - - private fun nevent(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) - - val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null - val relay = tlv.firstAsString(TlvTypes.RELAY) - val author = tlv.firstAsHex(TlvTypes.AUTHOR) - val kind = tlv.firstAsInt(TlvTypes.KIND.id) - - return Return(Type.EVENT, hex, relay, author, kind) - } - - private fun nrelay(bytes: ByteArray): Return? { - val relayUrl = Tlv.parse(bytes).firstAsString(TlvTypes.SPECIAL.id) ?: return null - - return Return(Type.RELAY, relayUrl) - } - - private fun naddr(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) - - val d = tlv.firstAsString(TlvTypes.SPECIAL.id) ?: "" - val relay = tlv.firstAsString(TlvTypes.RELAY.id) - val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null - val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null - - return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) - } - - public fun createNEvent( - idHex: String, - author: String?, - kind: Int?, - relay: String?, - ): String { - return TlvBuilder() - .apply { - addHex(TlvTypes.SPECIAL, idHex) - addStringIfNotNull(TlvTypes.RELAY, relay) - addHexIfNotNull(TlvTypes.AUTHOR, author) - addIntIfNotNull(TlvTypes.KIND, kind) - } - .build() - .toNEvent() - } -} - -fun decodePublicKey(key: String): ByteArray { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.hexToByteArray() - - return if (key.startsWith("nsec")) { - KeyPair(privKey = key.bechToBytes()).pubKey - } else if (pubKeyParsed != null) { - pubKeyParsed - } else { - Hex.decode(key) - } -} - -fun decodePublicKeyAsHexOrNull(key: String): HexKey? { - return try { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex - - if (key.startsWith("nsec")) { - KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() - } else if (pubKeyParsed != null) { - pubKeyParsed - } else { - Hex.decode(key).toHexKey() - } - } catch (e: Exception) { - null - } -} - -fun TlvBuilder.addString( - type: Nip19.TlvTypes, - string: String, -) = addString(type.id, string) - -fun TlvBuilder.addHex( - type: Nip19.TlvTypes, - key: HexKey, -) = addHex(type.id, key) - -fun TlvBuilder.addInt( - type: Nip19.TlvTypes, - data: Int, -) = addInt(type.id, data) - -fun TlvBuilder.addStringIfNotNull( - type: Nip19.TlvTypes, - data: String?, -) = addStringIfNotNull(type.id, data) - -fun TlvBuilder.addHexIfNotNull( - type: Nip19.TlvTypes, - data: HexKey?, -) = addHexIfNotNull(type.id, data) - -fun TlvBuilder.addIntIfNotNull( - type: Nip19.TlvTypes, - data: Int?, -) = addIntIfNotNull(type.id, data) - -fun Tlv.firstAsInt(type: Nip19.TlvTypes) = firstAsInt(type.id) - -fun Tlv.firstAsHex(type: Nip19.TlvTypes) = firstAsHex(type.id) - -fun Tlv.firstAsString(type: Nip19.TlvTypes) = firstAsString(type.id) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt new file mode 100644 index 000000000..0516f1916 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt @@ -0,0 +1,342 @@ +/** + * 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.quartz.encoders + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.events.Event +import kotlinx.coroutines.CancellationException +import java.io.ByteArrayOutputStream +import java.util.regex.Pattern +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +object Nip19Bech32 { + enum class Type { + USER, + NOTE, + EVENT, + RELAY, + ADDRESS, + } + + enum class TlvTypes(val id: Byte) { + SPECIAL(0), + RELAY(1), + AUTHOR(2), + KIND(3), + } + + val nip19regex = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + Pattern.CASE_INSENSITIVE, + ) + + @Immutable + data class ParseReturn(val entity: Entity, val additionalChars: String = "") + + interface Entity + + @Immutable + data class NSec(val hex: String) : Entity + + @Immutable + data class NPub(val hex: String) : Entity + + @Immutable + data class Note(val hex: String) : Entity + + @Immutable + data class NProfile(val hex: String, val relay: List) : Entity + + @Immutable + data class NEvent(val hex: String, val relay: List, val author: String?, val kind: Int?) : Entity + + @Immutable + data class NAddress(val atag: String, val relay: List, val author: String, val kind: Int) : Entity + + @Immutable + data class NRelay(val relay: List) : Entity + + @Immutable + data class NEmbed(val event: Event) : Entity + + fun uriToRoute(uri: String?): ParseReturn? { + if (uri == null) return null + + try { + val matcher = nip19regex.matcher(uri) + if (!matcher.find()) { + return null + } + + val type = matcher.group(2) // npub1 + val key = matcher.group(3) // bech32 + val additionalChars = matcher.group(4) // additional chars + + return parseComponents(type!!, key, additionalChars) + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) + } + + return null + } + + fun parseComponents( + type: String, + key: String?, + additionalChars: String?, + ): ParseReturn? { + return try { + val bytes = (type + key).bechToBytes() + + when (type.lowercase()) { + "npub1" -> npub(bytes) + "note1" -> note(bytes) + "nprofile1" -> nprofile(bytes) + "nevent1" -> nevent(bytes) + "nrelay1" -> nrelay(bytes) + "naddr1" -> naddr(bytes) + "nembed1" -> nembed(bytes) + else -> null + }?.let { + ParseReturn(it, additionalChars ?: "") + } + } catch (e: Throwable) { + Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) + null + } + } + + private fun nembed(bytes: ByteArray): NEmbed? { + if (bytes.isEmpty()) return null + return NEmbed(Event.fromJson(ungzip(bytes))) + } + + private fun npub(bytes: ByteArray): NPub? { + if (bytes.isEmpty()) return null + return NPub(bytes.toHexKey()) + } + + private fun note(bytes: ByteArray): Note? { + if (bytes.isEmpty()) return null + return Note(bytes.toHexKey()) + } + + private fun nprofile(bytes: ByteArray): NProfile? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null + val relay = tlv.asStringList(TlvTypes.RELAY) ?: emptyList() + + if (hex.isBlank()) return null + + return NProfile(hex, relay) + } + + private fun nevent(bytes: ByteArray): NEvent? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null + val relay = tlv.asStringList(TlvTypes.RELAY) ?: emptyList() + val author = tlv.firstAsHex(TlvTypes.AUTHOR) + val kind = tlv.firstAsInt(TlvTypes.KIND.id) + + if (hex.isBlank()) return null + + return NEvent(hex, relay, author, kind) + } + + private fun nrelay(bytes: ByteArray): NRelay? { + if (bytes.isEmpty()) return null + + val relayUrl = Tlv.parse(bytes).asStringList(TlvTypes.SPECIAL.id) ?: return null + + return NRelay(relayUrl) + } + + private fun naddr(bytes: ByteArray): NAddress? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val d = tlv.firstAsString(TlvTypes.SPECIAL.id) ?: "" + val relay = tlv.asStringList(TlvTypes.RELAY.id) ?: emptyList() + val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null + val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null + + return NAddress("$kind:$author:$d", relay, author, kind) + } + + fun createNEvent( + idHex: String, + author: String?, + kind: Int?, + relay: String?, + ): String { + return TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, idHex) + addStringIfNotNull(TlvTypes.RELAY, relay) + addHexIfNotNull(TlvTypes.AUTHOR, author) + addIntIfNotNull(TlvTypes.KIND, kind) + } + .build() + .toNEvent() + } + + fun createNEmbed(event: Event): String { + return gzip(event.toJson()).toNEmbed() + } + + fun gzip(content: String): ByteArray { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(content) } + val array = bos.toByteArray() + return array + } + + fun ungzip(content: ByteArray): String = GZIPInputStream(content.inputStream()).bufferedReader(Charsets.UTF_8).use { it.readText() } +} + +fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding.Bech32) + +fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32) + +fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32) + +fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32) + +fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32) + +fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32) + +fun ByteArray.toNEmbed() = Bech32.encodeBytes(hrp = "nembed", this, Bech32.Encoding.Bech32) + +fun decodePublicKey(key: String): ByteArray { + return when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { + is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey + is Nip19Bech32.NPub -> parsed.hex.hexToByteArray() + is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray() + else -> Hex.decode(key) // crashes on purpose + } +} + +fun decodePrivateKeyAsHexOrNull(key: String): HexKey? { + return try { + when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { + is Nip19Bech32.NSec -> parsed.hex + is Nip19Bech32.NPub -> null + is Nip19Bech32.NProfile -> null + is Nip19Bech32.Note -> null + is Nip19Bech32.NEvent -> null + is Nip19Bech32.NEmbed -> null + is Nip19Bech32.NRelay -> null + is Nip19Bech32.NAddress -> null + else -> Hex.decode(key).toHexKey() + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } +} + +fun decodePublicKeyAsHexOrNull(key: String): HexKey? { + return try { + when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { + is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() + is Nip19Bech32.NPub -> parsed.hex + is Nip19Bech32.NProfile -> parsed.hex + is Nip19Bech32.Note -> null + is Nip19Bech32.NEvent -> null + is Nip19Bech32.NEmbed -> null + is Nip19Bech32.NRelay -> null + is Nip19Bech32.NAddress -> null + else -> Hex.decode(key).toHexKey() + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } +} + +fun decodeEventIdAsHexOrNull(key: String): HexKey? { + return try { + when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { + is Nip19Bech32.NSec -> null + is Nip19Bech32.NPub -> null + is Nip19Bech32.NProfile -> null + is Nip19Bech32.Note -> parsed.hex + is Nip19Bech32.NEvent -> parsed.hex + is Nip19Bech32.NAddress -> parsed.atag + is Nip19Bech32.NEmbed -> null + is Nip19Bech32.NRelay -> null + else -> Hex.decode(key).toHexKey() + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } +} + +fun TlvBuilder.addString( + type: Nip19Bech32.TlvTypes, + string: String, +) = addString(type.id, string) + +fun TlvBuilder.addHex( + type: Nip19Bech32.TlvTypes, + key: HexKey, +) = addHex(type.id, key) + +fun TlvBuilder.addInt( + type: Nip19Bech32.TlvTypes, + data: Int, +) = addInt(type.id, data) + +fun TlvBuilder.addStringIfNotNull( + type: Nip19Bech32.TlvTypes, + data: String?, +) = addStringIfNotNull(type.id, data) + +fun TlvBuilder.addHexIfNotNull( + type: Nip19Bech32.TlvTypes, + data: HexKey?, +) = addHexIfNotNull(type.id, data) + +fun TlvBuilder.addIntIfNotNull( + type: Nip19Bech32.TlvTypes, + data: Int?, +) = addIntIfNotNull(type.id, data) + +fun Tlv.firstAsInt(type: Nip19Bech32.TlvTypes) = firstAsInt(type.id) + +fun Tlv.firstAsHex(type: Nip19Bech32.TlvTypes) = firstAsHex(type.id) + +fun Tlv.firstAsString(type: Nip19Bech32.TlvTypes) = firstAsString(type.id) + +fun Tlv.asStringList(type: Nip19Bech32.TlvTypes) = asStringList(type.id) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip30CustomEmoji.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip30CustomEmoji.kt new file mode 100644 index 000000000..809a10fdb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip30CustomEmoji.kt @@ -0,0 +1,113 @@ +/** + * 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.quartz.encoders + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.regex.Pattern + +class Nip30CustomEmoji { + companion object { + val customEmojiPattern: Pattern = + Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) + + fun fastMightContainEmoji( + input: String, + allTags: ImmutableListOfLists?, + ): Boolean { + if (allTags == null) return false + if (allTags.lists.any { it.size > 2 && it[0] == "emoji" }) return true + return input.contains(":") + } + + fun fastMightContainEmoji( + input: String, + emojiPairs: Map, + ): Boolean { + if (emojiPairs.isEmpty()) return false + return input.contains(":") + } + + fun createEmojiMap(tags: ImmutableListOfLists): Map { + return tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + } + + fun assembleAnnotatedList( + input: String, + allTags: ImmutableListOfLists?, + ): ImmutableList? { + if (allTags == null || allTags.lists.isEmpty()) return null + + val emojiPairs = createEmojiMap(allTags) + + return assembleAnnotatedList(input, emojiPairs) + } + + fun assembleAnnotatedList( + input: String, + emojiPairs: Map, + ): ImmutableList? { + val matcher = customEmojiPattern.matcher(input) + val emojiNamesInOrder = mutableListOf() + while (matcher.find()) { + emojiNamesInOrder.add(matcher.group()) + } + + if (emojiNamesInOrder.isEmpty()) { + return null + } + + val regularCharsInOrder = input.split(customEmojiPattern.toRegex()) + + val finalList = mutableListOf() + + // Merge the two lists in Order. + var index = 0 + for (word in regularCharsInOrder) { + if (word.isNotEmpty()) { + finalList.add(TextType(word)) + } + if (index < emojiNamesInOrder.size) { + val url = emojiPairs[emojiNamesInOrder[index]] + + if (url != null) { + finalList.add(ImageUrlType(url)) + } else { + if (word.isNotEmpty()) { + finalList.add(TextType(word)) + } + } + } + index++ + } + + return finalList.toImmutableList() + } + } + + @Immutable open class Renderable() + + @Immutable class TextType(val text: String) : Renderable() + + @Immutable class ImageUrlType(val url: String) : Renderable() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt new file mode 100644 index 000000000..0196b779b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt @@ -0,0 +1,56 @@ +/** + * 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.quartz.encoders + +import android.net.Uri +import kotlinx.coroutines.CancellationException + +// Rename to the corect nip number when ready. +class Nip47WalletConnect { + companion object { + fun parse(uri: String): Nip47URI { + // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D + + val url = Uri.parse(uri) + + if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { + throw IllegalArgumentException("Not a Wallet Connect QR Code") + } + + val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null") + + val pubkeyHex = + try { + decodePublicKey(pubkey).toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey") + } + + val relay = url.getQueryParameter("relay") + val secret = url.getQueryParameter("secret") + + return Nip47URI(pubkeyHex, relay, secret) + } + } + + data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt similarity index 59% rename from app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt index a3297527f..f9be3b979 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,16 +18,52 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.quartz.encoders +import com.vitorpamplona.quartz.events.FileHeaderEvent import java.net.URI import java.net.URLDecoder +import java.net.URLEncoder +import kotlin.coroutines.cancellation.CancellationException + +class Nip54InlineMetadata { + fun convertFromFileHeader(header: FileHeaderEvent): String? { + val myUrl = header.url() ?: return null + return createUrl( + myUrl, + header.tags, + ) + } + + fun createUrl( + imageUrl: String, + tags: Array>, + ): String { + val extension = + tags.mapNotNull { + if (it.isNotEmpty() && it[0] != "url") { + if (it.size > 1) { + "${it[0]}=${URLEncoder.encode(it[1], "utf-8")}" + } else { + "${it[0]}}=" + } + } else { + null + } + }.joinToString("&") + + return if (imageUrl.contains("#")) { + "$imageUrl&$extension" + } else { + "$imageUrl#$extension" + } + } -class Nip44UrlParser { fun parse(url: String): Map { return try { fragments(URI(url)) } catch (e: Exception) { + if (e is CancellationException) throw e emptyMap() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt new file mode 100644 index 000000000..bd217f884 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.encoders + +import com.vitorpamplona.quartz.events.FileHeaderEvent + +class Nip92MediaAttachments { + companion object { + private const val IMETA = "imeta" + } + + fun convertFromFileHeader(header: FileHeaderEvent): Array? { + val myUrl = header.url() ?: return null + return createTag( + myUrl, + header.tags, + ) + } + + fun createTag( + imageUrl: String, + tags: Array>, + ): Array { + return arrayOf( + IMETA, + "url $imageUrl", + ) + + tags.mapNotNull { + if (it.isNotEmpty() && it[0] != "url") { + if (it.size > 1) { + "${it[0]} ${it[1]}" + } else { + "${it[0]}}" + } + } else { + null + } + } + } + + fun parse( + imageUrl: String, + tags: Array>, + ): Map { + return tags.firstOrNull { + it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl" + }?.let { tagList -> + tagList.associate { tag -> + val parts = tag.split(" ", limit = 2) + when (parts.size) { + 2 -> parts[0] to parts[1] + 1 -> parts[0] to "" + else -> "" to "" + } + } + } ?: emptyMap() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt index e99a673bb..81254a688 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -94,6 +94,8 @@ class Tlv(val data: Map>) { fun firstAsString(type: Byte) = data[type]?.firstOrNull()?.toString(Charsets.UTF_8) + fun asStringList(type: Byte) = data[type]?.map { it.toString(Charsets.UTF_8) } + companion object { fun parse(data: ByteArray): Tlv { val result = mutableMapOf>() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt index 4510c390f..a2b6e1bda 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt index 1843ea7ce..8374c518d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt index 265a30717..6bc49b09d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt index 27c4b0785..db596d0c0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt index 68b087692..3724b2b13 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt index 545acc363..a4ebdb3d0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt index 1ed4596ce..41d76d2d8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt index 10cc2e147..0c235d6b2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index 5afa7657d..26a522e49 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip19 -import com.vitorpamplona.quartz.encoders.Nip19.nip19regex +import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.encoders.Nip19Bech32.nip19regex import java.util.regex.Pattern val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") @@ -42,18 +43,43 @@ open class BaseTextNoteEvent( ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun mentions() = taggedUsers() - open fun replyTos() = taggedEvents() + fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } + + fun forkFromAddress() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "fork" }?.let { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } + + fun forkFromVersion() = tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "fork" }?.get(1) + + open fun replyTos(): List { + val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } + val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + + val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot) + + return if (newStyleReplyTos.isNotEmpty()) { + newStyleReplyTos + } else { + oldStylePositional + } + } fun replyingTo(): HexKey? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1) - val newStyle = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - return newStyle ?: oldStylePositional + return newStyleReply ?: newStyleRoot ?: oldStylePositional } - @Transient private var citedUsersCache: Set? = null + @Transient private var citedUsersCache: Set? = null - @Transient private var citedNotesCache: Set? = null + @Transient private var citedNotesCache: Set? = null fun citedUsers(): Set { citedUsersCache?.let { @@ -74,19 +100,19 @@ open class BaseTextNoteEvent( val matcher2 = nip19regex.matcher(content) while (matcher2.find()) { - val uriScheme = matcher2.group(1) // nostr: val type = matcher2.group(2) // npub1 val key = matcher2.group(3) // bech32 val additionalChars = matcher2.group(4) // additional chars try { - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity if (parsed != null) { - val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } - - if (tag != null && tag[0] == "p") { - returningList.add(tag[1]) + if (parsed is Nip19Bech32.NProfile) { + returningList.add(parsed.hex) + } + if (parsed is Nip19Bech32.NPub) { + returningList.add(parsed.hex) } } } catch (e: Exception) { @@ -121,24 +147,18 @@ open class BaseTextNoteEvent( val matcher2 = nip19regex.matcher(content) while (matcher2.find()) { - val uriScheme = matcher2.group(1) // nostr: val type = matcher2.group(2) // npub1 val key = matcher2.group(3) // bech32 val additionalChars = matcher2.group(4) // additional chars - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity if (parsed != null) { - try { - val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } - - if (tag != null && tag[0] == "e") { - citations.add(tag[1]) - } - if (tag != null && tag[0] == "a") { - citations.add(tag[1]) - } - } catch (e: Exception) { + when (parsed) { + is Nip19Bech32.NEvent -> citations.add(parsed.hex) + is Nip19Bech32.NAddress -> citations.add(parsed.atag) + is Nip19Bech32.Note -> citations.add(parsed.hex) + is Nip19Bech32.NEmbed -> citations.add(parsed.event.id) } } } @@ -150,7 +170,10 @@ open class BaseTextNoteEvent( fun tagsWithoutCitations(): List { val repliesTo = replyTos() val tagAddresses = - taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND }.map { it.toTag() } + taggedAddresses().filter { + it.kind != CommunityDefinitionEvent.KIND && + it.kind != WikiNoteEvent.KIND + }.map { it.toTag() } if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() val citations = findCitations() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt index 86ed70ebe..ccd2392be 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt index 5df244d29..4adbf7908 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt index 40eb82a87..68095789c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt index 3c1fc2264..fe83c6d5b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt index 047fb8b6b..4ecf31c4f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt index 78b1797f1..0d081e9fb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt index ee2f47d07..5611deb0d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelListEvent.kt new file mode 100644 index 000000000..1837019ee --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelListEvent.kt @@ -0,0 +1,274 @@ +/** + * 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableSet + +@Immutable +class ChannelListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateEventCache: ImmutableSet? = null + + override fun dTag() = FIXED_D_TAG + + fun publicAndPrivateEvents( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + return + } + + privateTagsOrEmpty(signer) { + publicAndPrivateEventCache = filterTagList("e", it) + + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + } + } + } + + companion object { + const val KIND = 10005 + const val FIXED_D_TAG = "" + const val ALT = "Public Chat List" + + fun blockListFor(pubKeyHex: HexKey): String { + return "$KIND:$pubKeyHex:" + } + + fun createListWithTag( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithEvent( + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + return createListWithTag("e", eventId, isPrivate, signer, createdAt, onReady) + } + + fun addEvents( + earlierVersion: ChannelListEvent, + listEvents: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listEvents.map { arrayOf("e", it) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listEvents.map { arrayOf("e", it) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addEvent( + earlierVersion: ChannelListEvent, + event: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + return addTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: ChannelListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeEvent( + earlierVersion: ChannelListEvent, + event: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: ChannelListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index 265709839..c844be2d8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -58,7 +59,7 @@ class ChannelMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, onReady: (ChannelMessageEvent) -> Unit, ) { val tags = @@ -77,7 +78,9 @@ class ChannelMessageEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } } } tags.add( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt index 9868b91dc..8b7b9fae3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt index a96a5a898..fe9265495 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 77a62b9b6..1c2566d7b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet @@ -80,6 +81,7 @@ class ChatMessageEvent( geohash: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + nip94attachments: List? = null, onReady: (ChatMessageEvent) -> Unit, ) { val tags = mutableListOf>() @@ -95,6 +97,13 @@ class ChatMessageEvent( zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } subject?.let { tags.add(arrayOf("subject", it)) } + nip94attachments?.let { + it.forEach { + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + } // tags.add(arrayOf("alt", alt)) signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index 0d477f589..5b8597400 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt index 2978b34cf..38a72b2a0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityListEvent.kt new file mode 100644 index 000000000..21d692a90 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityListEvent.kt @@ -0,0 +1,279 @@ +/** + * 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet + +@Immutable +class CommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateEventCache: ImmutableSet? = null + + override fun dTag() = FIXED_D_TAG + + fun publicAndPrivateEvents( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + return + } + + privateTagsOrEmpty(signer) { + publicAndPrivateEventCache = + filterTagList("a", it) + .mapNotNull { ATag.parseAtag(it, null) } + .toImmutableSet() + + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + } + } + } + + companion object { + const val KIND = 10004 + const val FIXED_D_TAG = "" + const val ALT = "Community List" + + fun blockListFor(pubKeyHex: HexKey): String { + return "$KIND:$pubKeyHex:" + } + + fun createListWithTag( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithEvent( + address: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + return createListWithTag("a", address.toTag(), isPrivate, signer, createdAt, onReady) + } + + fun addEvents( + earlierVersion: CommunityListEvent, + listAddresses: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listAddresses.map { arrayOf("a", it.toTag()) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listAddresses.map { arrayOf("a", it.toTag()) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addEvent( + earlierVersion: CommunityListEvent, + address: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + return addTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: CommunityListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeEvent( + earlierVersion: CommunityListEvent, + address: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: CommunityListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt index 36ff66816..430ccbaae 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index ece905876..e57df43d8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -48,17 +48,18 @@ class ContactListEvent( @delegate:Transient val verifiedFollowKeySet: Set by lazy { - tags - .filter { it.size > 1 && it[0] == "p" } - .mapNotNull { - try { + tags.mapNotNullTo(HashSet()) { + try { + if (it.size > 1 && it[0] == "p") { decodePublicKey(it[1]).toHexKey() - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + } else { null } + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + null } - .toSet() + } } @delegate:Transient @@ -77,27 +78,29 @@ class ContactListEvent( @delegate:Transient val verifiedFollowKeySetAndMe: Set by lazy { verifiedFollowKeySet + pubKey } - fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowKeySet() = tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { it.getOrNull(1) } - fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowTagSet() = tags.filter { it.size > 1 && it[0] == "t" }.mapNotNull { it.getOrNull(1) } - fun unverifiedFollowGeohashSet() = tags.filter { it[0] == "g" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowGeohashSet() = tags.filter { it.size > 1 && it[0] == "g" }.mapNotNull { it.getOrNull(1) } - fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowAddressSet() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { it.getOrNull(1) } fun follows() = - tags - .filter { it[0] == "p" } - .mapNotNull { - try { + tags.mapNotNull { + try { + if (it.size > 1 && it[0] == "p") { Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2)) - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + } else { null } + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + null } + } - fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(2) } + fun followsTags() = tags.filter { it.size > 1 && it[0] == "t" }.mapNotNull { it.getOrNull(1) } fun relays(): Map? = try { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt index cb9bd04c5..7892b80a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt index 33d92aae4..73a09f013 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt index b294d6cad..b8b449ba3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 219283c45..aba51ab92 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -207,6 +207,8 @@ open class Event( override fun isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() + override fun isExpirationBefore(time: Long) = (expiration() ?: Long.MAX_VALUE) < time + override fun getTagOfAddressableKind(kind: Int): ATag? { val kindStr = kind.toString() val aTag = @@ -253,7 +255,7 @@ open class Event( return if (this is AddressableEvent) { ATag(kind, pubKey, dTag(), null).toNAddr() } else { - Nip19.createNEvent(id, pubKey, kind, null) + Nip19Bech32.createNEvent(id, pubKey, kind, null) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 6c29e297b..951901e4e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -53,14 +53,14 @@ class EventFactory { ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) ChatMessageEvent.KIND -> { if (id.isBlank()) { - val newId = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() ChatMessageEvent( - newId, + Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(), pubKey, createdAt, tags, @@ -74,6 +74,7 @@ class EventFactory { ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + CommunityListEvent.KIND -> CommunityListEvent(id, pubKey, createdAt, tags, content, sig) CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) @@ -86,8 +87,13 @@ class EventFactory { FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) + GitIssueEvent.KIND -> GitIssueEvent(id, pubKey, createdAt, tags, content, sig) + GitReplyEvent.KIND -> GitReplyEvent(id, pubKey, createdAt, tags, content, sig) + GitPatchEvent.KIND -> GitPatchEvent(id, pubKey, createdAt, tags, content, sig) + GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> @@ -106,6 +112,7 @@ class EventFactory { MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) + OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) @@ -122,6 +129,7 @@ class EventFactory { VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) + WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> Event(id, pubKey, createdAt, kind, tags, content, sig) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index d8c977288..55f0b4b2b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -137,5 +137,7 @@ interface EventInterface { fun isExpired(): Boolean + fun isExpirationBefore(time: Long): Boolean + fun hasZapSplitSetup(): Boolean } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FhirResourceEvent.kt similarity index 56% rename from app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/events/FhirResourceEvent.kt index 38d75338d..923fb47a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FhirResourceEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -18,38 +18,34 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import java.util.regex.Pattern +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class Nip30CustomEmoji { - val customEmojiPattern: Pattern = - Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) +class FhirResourceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 82 - fun buildArray(input: String): List { - val matcher = customEmojiPattern.matcher(input) - val list = mutableListOf() - while (matcher.find()) { - list.add(matcher.group()) - } - - if (list.isEmpty()) { - return listOf(input) - } - - val regularChars = input.split(customEmojiPattern.toRegex()) + fun create( + fhirPayload: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FhirResourceEvent) -> Unit, + ) { + val tags = mutableListOf>() - val finalList = mutableListOf() - var index = 0 - for (e in regularChars) { - finalList.add(e) - if (index < list.size) { - finalList.add(list[index]) - } - index++ + signer.sign(createdAt, KIND, tags.toTypedArray(), fhirPayload, onReady) } - return finalList } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 0c7a8fa0b..5d7e1863d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -62,17 +62,17 @@ class FileHeaderEvent( const val KIND = 1063 const val ALT_DESCRIPTION = "Verifiable file url" - private const val URL = "url" - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ORIGINAL_HASH = "ox" - private const val ALT = "alt" + const val URL = "url" + const val ENCRYPTION_KEY = "aes-256-gcm" + const val MIME_TYPE = "m" + const val FILE_SIZE = "size" + const val DIMENSION = "dim" + const val HASH = "x" + const val MAGNET_URI = "magnet" + const val TORRENT_INFOHASH = "i" + const val BLUR_HASH = "blurhash" + const val ORIGINAL_HASH = "ox" + const val ALT = "alt" fun create( url: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt index 25aef0b06..a2825b805 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt index e0a5aaddc..f5f6df63e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index 5b8ca0ef4..8ba7649ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt index 2563ad821..add6767f9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet +import java.util.HashSet @Immutable abstract class GeneralListEvent( @@ -61,12 +62,13 @@ abstract class GeneralListEvent( key: String, privateTags: Array>?, ): ImmutableSet { - val privateUserList = - privateTags?.let { it.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() } - ?: emptySet() - val publicUserList = tags.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() + val result = HashSet(tags.size + (privateTags?.size ?: 0)) - return (privateUserList + publicUserList).toImmutableSet() + privateTags?.let { it.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } } + + tags.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } + + return result.toImmutableSet() } fun isTagged( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt index afca0716d..0609f4739 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt index dc576da31..f844ee68d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -38,6 +38,10 @@ class GiftWrapEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { @Transient private var cachedInnerEvent: Map = mapOf() + fun preCachedGift(signer: NostrSigner): Event? { + return cachedInnerEvent[signer.pubKey] + } + fun cachedGift( signer: NostrSigner, onReady: (Event) -> Unit, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt new file mode 100644 index 000000000..ed11d2015 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt @@ -0,0 +1,79 @@ +/** + * 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GitIssueEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + private fun innerRepository() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } + ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } + + private fun repositoryHex() = innerRepository()?.getOrNull(1) + + fun rootIssueOrPath() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + + fun repository() = + innerRepository()?.let { + if (it.size > 1) { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } else { + null + } + } + + companion object { + const val KIND = 1621 + const val ALT = "A Git Issue" + + fun create( + patch: String, + createdAt: Long = TimeUtils.now(), + signer: NostrSigner, + onReady: (GitIssueEvent) -> Unit, + ) { + val content = patch + val tags = + mutableListOf( + arrayOf(), + ) + + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt new file mode 100644 index 000000000..d9a6670e1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitPatchEvent.kt @@ -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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GitPatchEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + private fun innerRepository() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } + ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } + + private fun repositoryHex() = innerRepository()?.getOrNull(1) + + fun repository() = + innerRepository()?.let { + if (it.size > 1) { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } else { + null + } + } + + fun commit() = tags.firstOrNull { it.size > 1 && it[0] == "commit" }?.get(1) + + fun parentCommit() = tags.firstOrNull { it.size > 1 && it[0] == "parent-commit" }?.get(1) + + fun commitPGPSig() = tags.firstOrNull { it.size > 1 && it[0] == "commit-pgp-sig" }?.get(1) + + fun committer() = + tags.filter { it.size > 1 && it[0] == "committer" }?.mapNotNull { + Committer(it.getOrNull(1), it.getOrNull(2), it.getOrNull(3), it.getOrNull(4)) + } + + data class Committer( + val name: String?, + val email: String?, + val timestamp: String?, + val timezoneInMinutes: String?, + ) + + companion object { + const val KIND = 1617 + const val ALT = "A Git Patch" + + fun create( + patch: String, + createdAt: Long = TimeUtils.now(), + signer: NostrSigner, + onReady: (GitPatchEvent) -> Unit, + ) { + val content = patch + val tags = + mutableListOf( + arrayOf(), + ) + + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt new file mode 100644 index 000000000..a6b1a5658 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt @@ -0,0 +1,79 @@ +/** + * 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GitReplyEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + private fun innerRepository() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } + ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } + + private fun repositoryHex() = innerRepository()?.getOrNull(1) + + fun repository() = + innerRepository()?.let { + if (it.size > 1) { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } else { + null + } + } + + fun rootIssueOrPath() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + + companion object { + const val KIND = 1622 + const val ALT = "A Git Issue" + + fun create( + patch: String, + createdAt: Long = TimeUtils.now(), + signer: NostrSigner, + onReady: (GitReplyEvent) -> Unit, + ) { + val content = patch + val tags = + mutableListOf( + arrayOf(), + ) + + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitRepositoryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitRepositoryEvent.kt new file mode 100644 index 000000000..f610dc7e2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitRepositoryEvent.kt @@ -0,0 +1,59 @@ +/** + * 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GitRepositoryEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + + fun web() = tags.firstOrNull { it.size > 1 && it[0] == "web" }?.get(1) + + fun clone() = tags.firstOrNull { it.size > 1 && it[0] == "clone" }?.get(1) + + companion object { + const val KIND = 30617 + const val ALT = "Git Repository" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GitRepositoryEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt index dc7fbf7cf..1ee839c04 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt index d4c380406..c51470ecd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt index 179c1ccb7..66b528e0d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index d2349f15a..4f4b46eb6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -70,7 +71,7 @@ class LiveActivitiesChatMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, onReady: (LiveActivitiesChatMessageEvent) -> Unit, ) { val content = message @@ -90,7 +91,9 @@ class LiveActivitiesChatMessageEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } } } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt index bd1f0f5a6..131d34c6e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt index 6bff48110..50407d634 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt index b0df739a7..f7fce1fd2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt index 63259472a..8b1bde062 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt index 5d260776b..7bb9c1db4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt index 20b7268ba..c9d20d852 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt index 009ed26e9..f003caa4e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt index 97fabab49..fdf5cfd1a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -35,17 +35,17 @@ class LongTextNoteEvent( content: String, sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressableEvent { - override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" override fun address() = ATag(kind, pubKey, dTag(), null) - fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + fun topics() = hashtags() - fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) fun publishedAt() = try { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index cdc3b7f72..a72995ae1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt index 80d4c650e..195819b6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt index 4fddd1925..5ff107a50 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -77,6 +77,7 @@ class NIP24Factory { markAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, onReady: (Result) -> Unit, ) { val senderPublicKey = signer.pubKey @@ -92,6 +93,7 @@ class NIP24Factory { markAsSensitive = markAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, ) { senderMessage -> createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> onReady( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt index 17316d851..73223548d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt new file mode 100644 index 000000000..f4f895322 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt @@ -0,0 +1,156 @@ +/** + * 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.quartz.events + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.ots.BlockstreamExplorer +import com.vitorpamplona.quartz.ots.CalendarPureJavaBuilder +import com.vitorpamplona.quartz.ots.DetachedTimestampFile +import com.vitorpamplona.quartz.ots.Hash +import com.vitorpamplona.quartz.ots.OpenTimestamps +import com.vitorpamplona.quartz.ots.VerifyResult +import com.vitorpamplona.quartz.ots.op.OpSHA256 +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import java.util.Base64 + +@Immutable +class OtsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient + var verifiedTime: Long? = null + + fun digestEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun digest() = digestEvent()?.hexToByteArray() + + fun otsByteArray(): ByteArray { + return Base64.getDecoder().decode(content) + } + + fun cacheVerify(): Long? { + return if (verifiedTime != null) { + verifiedTime + } else { + verifiedTime = verify() + verifiedTime + } + } + + fun verify(): Long? { + return digestEvent()?.let { OtsEvent.verify(otsByteArray(), it) } + } + + fun info(): String { + val detachedOts = DetachedTimestampFile.deserialize(otsByteArray()) + return otsInstance.info(detachedOts) + } + + companion object { + const val KIND = 1040 + const val ALT = "Opentimestamps Attestation" + + var otsInstance = OpenTimestamps(BlockstreamExplorer(), CalendarPureJavaBuilder()) + + fun stamp(eventId: HexKey): String { + val hash = Hash(eventId.hexToByteArray(), OpSHA256._TAG) + val file = DetachedTimestampFile.from(hash) + val timestamp = otsInstance.stamp(file) + val detachedToSerialize = DetachedTimestampFile(hash.getOp(), timestamp) + return Base64.getEncoder().encodeToString(detachedToSerialize.serialize()) + } + + fun upgrade( + otsFile: String, + eventId: HexKey, + ): String { + val detachedOts = DetachedTimestampFile.deserialize(Base64.getDecoder().decode(otsFile)) + + return if (otsInstance.upgrade(detachedOts)) { + // if the change is now verifiable. + if (verify(detachedOts, eventId) != null) { + Base64.getEncoder().encodeToString(detachedOts.serialize()) + } else { + otsFile + } + } else { + otsFile + } + } + + fun verify( + otsFile: String, + eventId: HexKey, + ): Long? { + return verify(Base64.getDecoder().decode(otsFile), eventId) + } + + fun verify( + otsFile: ByteArray, + eventId: HexKey, + ): Long? { + return verify(DetachedTimestampFile.deserialize(otsFile), eventId) + } + + fun verify( + detachedOts: DetachedTimestampFile, + eventId: HexKey, + ): Long? { + try { + val result = otsInstance.verify(detachedOts, eventId.hexToByteArray()) + if (result == null || result.isEmpty()) { + return null + } else { + return result.get(VerifyResult.Chains.BITCOIN)?.timestamp + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("OpenTimeStamps", "Failed to verify", e) + return null + } + } + + fun create( + eventId: HexKey, + otsFileBase64: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (OtsEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("e", eventId), + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, otsFileBase64, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt index ec9bae9bd..8c35d3410 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt index 155ef83eb..185df6818 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 3d9a5dc57..80d63d66a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -78,7 +79,7 @@ class PollNoteEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, onReady: (PollNoteEvent) -> Unit, ) { val tags = mutableListOf>() @@ -104,7 +105,9 @@ class PollNoteEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } } } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index d5475ca39..adf4c1213 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexValidator +import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentSetOf @@ -122,14 +123,24 @@ class PrivateDmEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, + nip94attachments: List? = null, onReady: (PrivateDmEvent) -> Unit, ) { - val message = + var message = msg + nip94attachments?.forEach { + val myUrl = it.url() + if (myUrl != null) { + message = message.replace(myUrl, Nip54InlineMetadata().createUrl(myUrl, it.tags)) + } + } + + message = if (advertiseNip18) { - NIP_18_ADVERTISEMENT + NIP_18_ADVERTISEMENT + message } else { - "" - } + msg + message + } + val tags = mutableListOf>() publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) } replyTos?.forEach { tags.add(arrayOf("e", it)) } @@ -142,6 +153,15 @@ class PrivateDmEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } + /* Privacy issue: DO NOT ADD THESE TO THE TAGS. + nip94attachments?.let { + it.forEach { + Nip92().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + }*/ + tags.add(arrayOf("alt", ALT)) signer.nip04Encrypt(message, recipientPubKey) { content -> diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt index da1ca943a..a3c9b53d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt index 63b2c39d2..e217c86a1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt index 9aa0060ea..8520d2fbf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt index a8da5f7dd..80ec8586c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt index 255a1641e..0535c639d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -126,5 +126,6 @@ class ReportEvent( IMPERSONATION, NUDITY, PROFANITY, + OTHER, } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt index ec12ce9f8..056182829 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt index 610baabf2..4fd6194ef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -39,6 +39,10 @@ class SealedGossipEvent( ) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { @Transient private var cachedInnerEvent: Map = mapOf() + fun preCachedGossip(signer: NostrSigner): Event? { + return cachedInnerEvent[signer.pubKey] + } + fun cachedGossip( signer: NostrSigner, onReady: (Event) -> Unit, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt index 962e8bd91..aa897e286 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 7abb7b242..79a3161b2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -25,6 +25,7 @@ import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -44,18 +45,19 @@ class TextNoteEvent( fun create( msg: String, - replyTos: List?, - mentions: List?, - addresses: List?, - extraTags: List?, + replyTos: List? = null, + mentions: List? = null, + addresses: List? = null, + extraTags: List? = null, zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - replyingTo: String?, - root: String?, - directMentions: Set, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + replyingTo: String? = null, + root: String? = null, + directMentions: Set = emptySet(), geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, + forkedFrom: Event? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (TextNoteEvent) -> Unit, @@ -68,6 +70,7 @@ class TextNoteEvent( root = root, replyingTo = replyingTo, directMentions = directMentions, + forkedFrom = forkedFrom?.id, ), ) } @@ -78,6 +81,11 @@ class TextNoteEvent( tags.add(arrayOf("p", it)) } } + replyTos?.forEach { + if (it in directMentions) { + tags.add(arrayOf("q", it)) + } + } addresses ?.map { it.toTag() } ?.let { @@ -87,6 +95,7 @@ class TextNoteEvent( root = root, replyingTo = replyingTo, directMentions = directMentions, + forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), ), ) } @@ -106,7 +115,9 @@ class TextNoteEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } } } @@ -128,6 +139,7 @@ class TextNoteEvent( root: String?, replyingTo: String?, directMentions: Set, + forkedFrom: String?, ) = sortedWith { o1, o2 -> when { o1 == o2 -> 0 @@ -142,6 +154,7 @@ class TextNoteEvent( when (it) { root -> arrayOf(tagName, it, "", "root") replyingTo -> arrayOf(tagName, it, "", "reply") + forkedFrom -> arrayOf(tagName, it, "", "fork") in directMentions -> arrayOf(tagName, it, "", "mention") else -> arrayOf(tagName, it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 698d17a42..54a438953 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt index ff6a5f87f..b9acc1cd7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt index 412c9e542..6787627f8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt index 1a7d0ebdb..2254924a2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt new file mode 100644 index 000000000..a08bb9992 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt @@ -0,0 +1,77 @@ +/** + * 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class WikiNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressableEvent { + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + + override fun address() = ATag(kind, pubKey, dTag(), null) + + fun topics() = hashtags() + + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + + fun publishedAt() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null + } + + companion object { + const val KIND = 30818 + + fun create( + msg: String, + title: String?, + replyTos: List?, + mentions: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (WikiNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + title?.let { tags.add(arrayOf("title", it)) } + tags.add(arrayOf("alt", "Wiki Post: $title")) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/BitcoinExplorer.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/BitcoinExplorer.java new file mode 100644 index 000000000..5a5bbd811 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/BitcoinExplorer.java @@ -0,0 +1,21 @@ +package com.vitorpamplona.quartz.ots; + +public interface BitcoinExplorer { + /** + * Retrieve the block information from the block hash. + * + * @param hash Hash of the block. + * @return the blockheader of the hash + * @throws Exception desc + */ + public BlockHeader block(final String hash) throws Exception; + + /** + * 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 + */ + public String blockHash(final Integer height) throws Exception; +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/BlockHeader.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/BlockHeader.java new file mode 100644 index 000000000..3d0983d1f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/BlockHeader.java @@ -0,0 +1,73 @@ +package com.vitorpamplona.quartz.ots; + +public class BlockHeader { + + private String merkleroot; + private String blockHash; + private String time; + + public void setTime(String time) { + this.time = time; + } + + public Long getTime() { + return Long.valueOf(time); + } + + public String getMerkleroot() { + return merkleroot; + } + + public void setMerkleroot(String merkleroot) { + this.merkleroot = merkleroot; + } + + public String getBlockHash() { + return blockHash; + } + + public void setBlockHash(String blockHash) { + this.blockHash = blockHash; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + BlockHeader that = (BlockHeader) o; + + if (merkleroot != null ? !merkleroot.equals(that.merkleroot) : that.merkleroot != null) { + return false; + } + + if (blockHash != null ? !blockHash.equals(that.blockHash) : that.blockHash != null) { + return false; + } + + return time != null ? time.equals(that.time) : that.time == null; + } + + @Override + public int hashCode() { + int result = merkleroot != null ? merkleroot.hashCode() : 0; + result = 31 * result + (blockHash != null ? blockHash.hashCode() : 0); + result = 31 * result + (time != null ? time.hashCode() : 0); + + return result; + } + + @Override + public String toString() { + return "BlockHeader{" + + "merkleroot='" + merkleroot + '\'' + + ", blockHash='" + blockHash + '\'' + + ", time='" + time + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/BlockstreamExplorer.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/BlockstreamExplorer.java new file mode 100644 index 000000000..01c84254f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/BlockstreamExplorer.java @@ -0,0 +1,64 @@ +package com.vitorpamplona.quartz.ots; + +import android.util.Log; + +import com.fasterxml.jackson.databind.JsonNode; +import com.vitorpamplona.quartz.ots.http.Request; +import com.vitorpamplona.quartz.ots.http.Response; + +import java.net.URL; +import java.util.concurrent.*; + +public class BlockstreamExplorer implements BitcoinExplorer { + private static final String esploraUrl = "https://blockstream.info/api"; + + /** + * Retrieve the block information from the block hash. + * + * @param hash Hash of the block. + * @return the blockheader of the hash + * @throws Exception desc + */ + public BlockHeader block(final String hash) throws Exception { + final URL url = new URL(esploraUrl + "/block/" + hash); + final Request task = new Request(url); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final Future future = executor.submit(task); + final Response take = future.get(); + executor.shutdown(); + if (!take.isOk()) + throw new Exception(); + + final JsonNode jsonObject = take.getJson(); + final String merkleroot = jsonObject.get("merkle_root").asText(); + final String time = String.valueOf(jsonObject.get("timestamp").asInt()); + final BlockHeader blockHeader = new BlockHeader(); + blockHeader.setMerkleroot(merkleroot); + blockHeader.setTime(time); + blockHeader.setBlockHash(hash); + Log.i("BlockstreamExplorer", take.getFromUrl() + " " + blockHeader); + return blockHeader; + //log.warning("Cannot parse merkleroot from body: " + jsonObject + ": " + e.getMessage()); + } + + /** + * 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 + */ + public String blockHash(final Integer height) throws Exception { + final URL url = new URL(esploraUrl + "/block-height/" + height); + final Request task = new Request(url); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final Future future = executor.submit(task); + final Response take = future.get(); + executor.shutdown(); + if (!take.isOk()) + throw new Exception(); + final String blockHash = take.getString(); + Log.i("BlockstreamExplorer", take.getFromUrl() + " " + blockHash); + return blockHash; + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/Calendar.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Calendar.java new file mode 100644 index 000000000..27c870d8a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Calendar.java @@ -0,0 +1,122 @@ +package com.vitorpamplona.quartz.ots; + +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 com.vitorpamplona.quartz.ots.http.Response; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * Class representing remote calendar server interface. + */ +public class Calendar implements ICalendar { + + private String url; + + + /** + * Create a RemoteCalendar. + * + * @param url The server url. + */ + public Calendar(String url) { + this.url = url; + } + + /** + * Get calendar url. + * + * @return The calendar url. + */ + public String getUrl() { + return this.url; + } + + /** + * 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 + */ + @Override + public Timestamp submit(byte[] digest) + throws ExceededSizeException, UrlException, DeserializationException { + try { + Map headers = new HashMap<>(); + headers.put("Accept", "application/vnd.opentimestamps.v1"); + headers.put("User-Agent", "java-opentimestamps"); + headers.put("Content-Type", "application/x-www-form-urlencoded"); + + URL obj = new URL(url + "/digest"); + Request task = new Request(obj); + task.setData(digest); + task.setHeaders(headers); + Response response = task.call(); + byte[] body = response.getBytes(); + if (body.length > 10000) { + throw new ExceededSizeException("Calendar response exceeded size limit"); + } + + StreamDeserializationContext ctx = new StreamDeserializationContext(body); + return Timestamp.deserialize(ctx, digest); + } catch (ExceededSizeException | DeserializationException e) + { + throw e; + } + catch (Exception e) { + throw new UrlException(e.getMessage()); + } + } + + /** + * 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 + */ + @Override + public Timestamp getTimestamp(byte[] commitment) throws DeserializationException, ExceededSizeException, CommitmentNotFoundException, UrlException { + try { + Map headers = new HashMap<>(); + headers.put("Accept", "application/vnd.opentimestamps.v1"); + headers.put("User-Agent", "java-opentimestamps"); + headers.put("Content-Type", "application/x-www-form-urlencoded"); + + URL obj = new URL(url + "/timestamp/" + Utils.bytesToHex(commitment).toLowerCase()); + Request task = new Request(obj); + task.setHeaders(headers); + Response response = task.call(); + byte[] body = response.getBytes(); + if (body.length > 10000) { + throw new ExceededSizeException("Calendar response exceeded size limit"); + } + + if (!response.isOk()) { + throw new CommitmentNotFoundException("com.eternitywall.ots.Calendar response a status code != 200 which is: " + response.getStatus()); + } + + StreamDeserializationContext ctx = new StreamDeserializationContext(body); + + return Timestamp.deserialize(ctx, commitment); + } + catch (DeserializationException | ExceededSizeException | CommitmentNotFoundException e) + { + throw e; + } + catch (Exception e) { + throw new UrlException(e.getMessage()); + } + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarAsyncSubmit.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarAsyncSubmit.java new file mode 100644 index 000000000..08b69a261 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarAsyncSubmit.java @@ -0,0 +1,58 @@ +package com.vitorpamplona.quartz.ots; + +import android.util.Log; + +import com.vitorpamplona.quartz.ots.http.Request; +import com.vitorpamplona.quartz.ots.http.Response; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; + +/** + * For making async calls to a calendar server + */ +public class CalendarAsyncSubmit implements ICalendarAsyncSubmit { + + private String url; + private byte[] digest; + private BlockingQueue> queue; + + public CalendarAsyncSubmit(String url, byte[] digest) { + this.url = url; + this.digest = digest; + } + + public void setQueue(BlockingQueue> queue) { + this.queue = queue; + } + + @Override + public Optional call() throws Exception { + Map headers = new HashMap<>(); + headers.put("Accept", "application/vnd.opentimestamps.v1"); + headers.put("User-Agent", "java-opentimestamps"); + headers.put("Content-Type", "application/x-www-form-urlencoded"); + + URL obj = new URL(url + "/digest"); + Request task = new Request(obj); + task.setData(digest); + task.setHeaders(headers); + Response response = task.call(); + + if (response.isOk()) { + byte[] body = response.getBytes(); + StreamDeserializationContext ctx = new StreamDeserializationContext(body); + Timestamp timestamp = Timestamp.deserialize(ctx, digest); + Optional of = Optional.of(timestamp); + queue.add(of); + return of; + } + + queue.add(Optional.empty()); + + return Optional.empty(); + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarBuilder.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarBuilder.java new file mode 100644 index 000000000..f630401e4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarBuilder.java @@ -0,0 +1,6 @@ +package com.vitorpamplona.quartz.ots; + +public interface CalendarBuilder { + public ICalendar newSyncCalendar(String url); + public ICalendarAsyncSubmit newAsyncCalendar(String url, byte[] digest); +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarPureJavaBuilder.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarPureJavaBuilder.java new file mode 100644 index 000000000..d75b65b4c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/CalendarPureJavaBuilder.java @@ -0,0 +1,10 @@ +package com.vitorpamplona.quartz.ots; + +public class CalendarPureJavaBuilder implements CalendarBuilder { + public ICalendar newSyncCalendar(String url) { + return new Calendar(url); + } + public ICalendarAsyncSubmit newAsyncCalendar(String url, byte[] digest) { + return new CalendarAsyncSubmit(url, digest); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/DetachedTimestampFile.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/DetachedTimestampFile.java new file mode 100644 index 000000000..1b212316d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/DetachedTimestampFile.java @@ -0,0 +1,227 @@ +package com.vitorpamplona.quartz.ots; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import com.vitorpamplona.quartz.ots.op.Op; +import com.vitorpamplona.quartz.ots.op.OpCrypto; +import com.vitorpamplona.quartz.ots.op.OpSHA256; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.security.NoSuchAlgorithmException; + +/** + * Class representing Detached com.vitorpamplona.quartz.ots.Timestamp File. + * A file containing a timestamp for another file. + * Contains a timestamp, along with a header and the digest of the file. + */ +public class DetachedTimestampFile { + + /** + * Header magic bytes Designed to be give the user some information in a hexdump, while being + * identified as 'data' by the file utility. + * + * @default \x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94 + */ + static byte[] HEADER_MAGIC = {(byte) 0x00, (byte) 0x4f, (byte) 0x70, (byte) 0x65, (byte) 0x6e, + (byte) 0x54, (byte) 0x69, (byte) 0x6d, (byte) 0x65, (byte) 0x73, + (byte) 0x74, (byte) 0x61, (byte) 0x6d, (byte) 0x70, (byte) 0x73, (byte) 0x00, (byte) 0x00, + (byte) 0x50, (byte) 0x72, (byte) 0x6f, (byte) 0x6f, (byte) 0x66, (byte) 0x00, + (byte) 0xbf, (byte) 0x89, (byte) 0xe2, (byte) 0xe8, (byte) 0x84, (byte) 0xe8, (byte) 0x92, + (byte) 0x94}; + + /** + * While the git commit timestamps have a minor version, probably better to + * leave it out here: unlike Git commits round-tripping is an issue when + * timestamps are upgraded, and we could end up with bugs related to not + * saving/updating minor version numbers correctly. + * + * @default 1 + */ + static byte MAJOR_VERSION = 1; + + Op fileHashOp; + Timestamp timestamp; + + public DetachedTimestampFile(Op fileHashOp, Timestamp timestamp) { + this.fileHashOp = fileHashOp; + this.timestamp = timestamp; + } + + /** + * The digest of the file that was timestamped. + * + * @return The message inside the timestamp. + */ + public byte[] fileDigest() { + return this.timestamp.msg; + } + + /** + * Retrieve the internal timestamp. + * + * @return the timestamp. + */ + public Timestamp getTimestamp() { + return this.timestamp; + } + + /** + * Serialize a com.vitorpamplona.quartz.ots.Timestamp File. + * + * @param ctx The stream serialization context. + */ + public void serialize(StreamSerializationContext ctx) { + ctx.writeBytes(HEADER_MAGIC); + ctx.writeVaruint(MAJOR_VERSION); + this.fileHashOp.serialize(ctx); + ctx.writeBytes(this.timestamp.msg); + this.timestamp.serialize(ctx); + } + + /** + * Serialize a com.vitorpamplona.quartz.ots.Timestamp File. + * + * @return The byte array of serialized data. + */ + public byte[] serialize() { + StreamSerializationContext ctx = new StreamSerializationContext(); + this.serialize(ctx); + + return ctx.getOutput(); + } + + /** + * Deserialize a com.vitorpamplona.quartz.ots.Timestamp File. + * + * @param ctx The stream deserialization context. + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + */ + public static DetachedTimestampFile deserialize(StreamDeserializationContext ctx) throws DeserializationException { + ctx.assertMagic(HEADER_MAGIC); + ctx.readVaruint(); + + OpCrypto fileHashOp = (OpCrypto) OpCrypto.deserialize(ctx); + byte[] fileHash = ctx.readBytes(fileHashOp._DIGEST_LENGTH()); + Timestamp timestamp = Timestamp.deserialize(ctx, fileHash); + + ctx.assertEof(); + + return new DetachedTimestampFile(fileHashOp, timestamp); + } + + /** + * Deserialize a com.vitorpamplona.quartz.ots.Timestamp File. + * + * @param ots The byte array of deserialization DetachedFileTimestamped. + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + */ + public static DetachedTimestampFile deserialize(byte[] ots) throws DeserializationException { + StreamDeserializationContext ctx = new StreamDeserializationContext(ots); + + return DetachedTimestampFile.deserialize(ctx); + } + + /** + * Read the Detached com.vitorpamplona.quartz.ots.Timestamp File from bytes. + * + * @param fileHashOp The file hash operation. + * @param ctx The stream deserialization context. + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + * @throws NoSuchAlgorithmException desc + */ + public static DetachedTimestampFile from(OpCrypto fileHashOp, StreamDeserializationContext ctx) throws NoSuchAlgorithmException { + byte[] fdHash = fileHashOp.hashFd(ctx); + + return new DetachedTimestampFile(fileHashOp, new Timestamp(fdHash)); + } + + /** + * Read the Detached com.vitorpamplona.quartz.ots.Timestamp File from bytes. + * + * @param fileHashOp The file hash operation. + * @param bytes The byte array of data to hash + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + * @throws NoSuchAlgorithmException desc + */ + public static DetachedTimestampFile from(OpCrypto fileHashOp, byte[] bytes) throws Exception { + byte[] fdHash = fileHashOp.hashFd(bytes); + + return new DetachedTimestampFile(fileHashOp, new Timestamp(fdHash)); + } + + /** + * Read the Detached com.vitorpamplona.quartz.ots.Timestamp File from hash. + * + * @param inputStream The InputStream of the file to hash + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + * @throws Exception if the input stream is null + */ + public static DetachedTimestampFile from(InputStream inputStream) throws Exception { + if (inputStream == null) { + throw new Exception(); // TODO: Add exception string later on + } + + try { + DetachedTimestampFile fileTimestamp = DetachedTimestampFile.from(new OpSHA256(), inputStream); // Read from file reader stream + return fileTimestamp; + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + throw new Exception(); + } + } + + /** + * Read the Detached com.vitorpamplona.quartz.ots.Timestamp File from InputStream. + * + * @param fileHashOp The file hash operation. + * @param inputStream The input stream file. + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + * @throws IOException desc + * @throws NoSuchAlgorithmException desc + */ + public static DetachedTimestampFile from(OpCrypto fileHashOp, InputStream inputStream) throws IOException, NoSuchAlgorithmException { + byte[] fdHash = fileHashOp.hashFd(inputStream); + + return new DetachedTimestampFile(fileHashOp, new Timestamp(fdHash)); + } + + /** + * Read the Detached com.vitorpamplona.quartz.ots.Timestamp File from hash. + * + * @param hash The hash of the file. + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + */ + public static DetachedTimestampFile from(Hash hash) { + return new DetachedTimestampFile(hash.getOp(), new Timestamp(hash.getValue())); + } + + /** + * Read the Detached com.vitorpamplona.quartz.ots.Timestamp File from File. + * + * @param fileHashOp The file hash operation. + * @param file The hash file. + * @return The generated com.vitorpamplona.quartz.ots.DetachedTimestampFile object. + * @throws IOException desc + * @throws NoSuchAlgorithmException desc + */ + public static DetachedTimestampFile from(OpCrypto fileHashOp, File file) throws IOException, NoSuchAlgorithmException { + byte[] fdHash = fileHashOp.hashFd(file); + + return new DetachedTimestampFile(fileHashOp, new Timestamp(fdHash)); + } + + /** + * Print the object. + * + * @return The output. + */ + @Override + public String toString() { + String output = "com.vitorpamplona.quartz.ots.DetachedTimestampFile\n"; + output += "fileHashOp: " + this.fileHashOp.toString() + '\n'; + output += "timestamp: " + this.timestamp.toString() + '\n'; + + return output; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/Hash.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Hash.java new file mode 100644 index 000000000..4d1375f5f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Hash.java @@ -0,0 +1,198 @@ +package com.vitorpamplona.quartz.ots; + +import com.vitorpamplona.quartz.ots.op.OpCrypto; +import com.vitorpamplona.quartz.ots.op.OpKECCAK256; +import com.vitorpamplona.quartz.ots.op.OpRIPEMD160; +import com.vitorpamplona.quartz.ots.op.OpSHA1; +import com.vitorpamplona.quartz.ots.op.OpSHA256; +import com.vitorpamplona.quartz.encoders.Hex; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.security.NoSuchAlgorithmException; + +public class Hash { + + private byte[] value; + private byte algorithm; + + /** + * Create a Hash object. + * + * @param value - The byte array of the hash + * @param algorithm - The hashlib tag of crypto operation + */ + public Hash(byte[] value, byte algorithm) { + this.value = value; + this.algorithm = algorithm; + } + + /** + * Create a Hash object. + * + * @param value - The byte array of the hash + * @param label - The hashlib name of crypto operation + */ + public Hash(byte[] value, String label) { + this.value = value; + this.algorithm = getOp(label)._TAG(); + } + + /** + * Get Value. + * + * @return value - The hash in byte array. + */ + public byte[] getValue() { + return value; + } + + /** + * Set Value tag. + * + * @param value - The hash in byte array. + */ + public void setValue(byte[] value) { + this.value = value; + } + + /** + * Get Algorithm tag. + * + * @return algorithm - The algorithm tag of crypto operation. + */ + public byte getAlgorithm() { + return algorithm; + } + + /** + * Set Algorithm tag. + * + * @param algorithm - The algorithm tag of crypto operation. + */ + public void setAlgorithm(byte algorithm) { + this.algorithm = algorithm; + } + + /** + * Get the current Crypto operation. + * + * @return The generated com.vitorpamplona.quartz.ots.OpCrypto object. + */ + public OpCrypto getOp() { + if (this.algorithm == OpSHA1._TAG) { + return new OpSHA1(); + } else if (this.algorithm == OpSHA256._TAG) { + return new OpSHA256(); + } else if (this.algorithm == OpRIPEMD160._TAG) { + return new OpRIPEMD160(); + } else if (this.algorithm == OpKECCAK256._TAG) { + return new OpKECCAK256(); + } + + return new OpSHA256(); + } + + /** + * Get Crypto operation from hashlib tag. + * + * @param algorithm The hashlib tag. + * @return The generated com.vitorpamplona.quartz.ots.OpCrypto object. + */ + public static OpCrypto getOp(byte algorithm) { + if (algorithm == OpSHA1._TAG) { + return new OpSHA1(); + } else if (algorithm == OpSHA256._TAG) { + return new OpSHA256(); + } else if (algorithm == OpRIPEMD160._TAG) { + return new OpRIPEMD160(); + } else if (algorithm == OpKECCAK256._TAG) { + return new OpKECCAK256(); + } + + return new OpSHA256(); + } + + /** + * Get Crypto operation from hashlib name. + * + * @param label The hashlib name. + * @return The generated com.vitorpamplona.quartz.ots.OpCrypto object. + */ + public static OpCrypto getOp(String label) { + if (label.toLowerCase().equals(new OpSHA1()._TAG_NAME())) { + return new OpSHA1(); + } else if (label.toLowerCase().equals(new OpSHA256()._TAG_NAME())) { + return new OpSHA256(); + } else if (label.toLowerCase().equals(new OpRIPEMD160()._TAG_NAME())) { + return new OpRIPEMD160(); + } else if (label.toLowerCase().equals(new OpKECCAK256()._TAG_NAME())) { + return new OpKECCAK256(); + } + + return new OpSHA256(); + } + + /** + * Build hash from data. + * + * @param bytes The byte array of data to hash. + * @param algorithm The hash file. + * @return The generated com.vitorpamplona.quartz.ots.Hash object. + * @throws IOException desc + * @throws NoSuchAlgorithmException desc + */ + public static Hash from(byte[] bytes, byte algorithm) throws IOException, NoSuchAlgorithmException { + OpCrypto opCrypto = getOp(algorithm); + byte[] value = opCrypto.hashFd(bytes); + + return new Hash(value, algorithm); + } + + /** + * Build hash from File. + * + * @param file The File of data to hash. + * @param algorithm The hash file. + * @return The generated com.vitorpamplona.quartz.ots.Hash object. + * @throws IOException desc + * @throws NoSuchAlgorithmException desc + */ + public static Hash from(File file, byte algorithm) throws IOException, NoSuchAlgorithmException { + OpCrypto opCrypto = getOp(algorithm); + byte[] value = opCrypto.hashFd(file); + + return new Hash(value, algorithm); + } + + /** + * Build hash from InputStream. + * + * @param inputStream The InputStream of data to hash. + * @param algorithm The hash file. + * @return The generated com.vitorpamplona.quartz.ots.Hash object. + * @throws IOException desc + * @throws NoSuchAlgorithmException desc + */ + public static Hash from(InputStream inputStream, byte algorithm) throws IOException, NoSuchAlgorithmException { + OpCrypto opCrypto = getOp(algorithm); + byte[] value = opCrypto.hashFd(inputStream); + + return new Hash(value, algorithm); + } + + /** + * Print the object. + * + * @return The output. + */ + @Override + public String toString() { + String output = "com.vitorpamplona.quartz.ots.Hash\n"; + output += "algorithm: " + this.getOp()._HASHLIB_NAME() + '\n'; + output += "value: " + Hex.encode(this.value) + '\n'; + + return output; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/ICalendar.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/ICalendar.java new file mode 100644 index 000000000..5f04fa40b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/ICalendar.java @@ -0,0 +1,13 @@ +package com.vitorpamplona.quartz.ots; + +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; + +public interface ICalendar { + Timestamp submit(byte[] digest) + throws ExceededSizeException, UrlException, DeserializationException; + + Timestamp getTimestamp(byte[] commitment) throws DeserializationException, ExceededSizeException, CommitmentNotFoundException, UrlException; +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/ICalendarAsyncSubmit.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/ICalendarAsyncSubmit.java new file mode 100644 index 000000000..9dd821150 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/ICalendarAsyncSubmit.java @@ -0,0 +1,9 @@ +package com.vitorpamplona.quartz.ots; + +import java.util.Optional; +import java.util.concurrent.Callable; + +public interface ICalendarAsyncSubmit extends Callable> { + @Override + Optional call() throws Exception; +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/Merkle.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Merkle.java new file mode 100644 index 000000000..59222c5e4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Merkle.java @@ -0,0 +1,95 @@ +package com.vitorpamplona.quartz.ots; + +import com.vitorpamplona.quartz.ots.op.OpAppend; +import com.vitorpamplona.quartz.ots.op.OpPrepend; +import com.vitorpamplona.quartz.ots.op.OpSHA256; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility functions for merkle trees + */ +public class Merkle { + + /** + * Concatenate left and right, then perform a unary operation on them left and right can be either timestamps or bytes. + * Appropriate intermediary append/prepend operations will be created as needed for left and right. + * + * @param left the left timestamp parameter + * @param right the right timestamp parameter + * @return the concatenation of left and right + */ + public static Timestamp catThenUnaryOp(Timestamp left, Timestamp right) { + // rightPrependStamp = right.ops.add(OpPrepend(left.msg)) + Timestamp rightPrependStamp = right.add(new OpPrepend(left.msg)); + + // Left and right should produce the same thing, so we can set the timestamp of the left to the right. + // left.ops[OpAppend(right.msg)] = right_prepend_stamp + // leftAppendStamp = left.ops.add(OpAppend(right.msg)) + //Timestamp leftPrependStamp = left.add(new OpAppend(right.msg)); + left.ops.put(new OpAppend(right.msg), rightPrependStamp); + + // return rightPrependStamp.ops.add(unaryOpCls()) + Timestamp res = rightPrependStamp.add(new OpSHA256()); + return res; + } + + public static Timestamp catSha256(Timestamp left, Timestamp right) { + return Merkle.catThenUnaryOp(left, right); + } + + public static Timestamp catSha256d(Timestamp left, Timestamp right) { + Timestamp sha256Timestamp = Merkle.catSha256(left, right); + // res = sha256Timestamp.ops.add(OpSHA256()); + Timestamp res = sha256Timestamp.add(new OpSHA256()); + return res; + } + + /** + * Merkelize a set of timestamps. + * A merkle tree of all the timestamps is built in-place using binop() to + * timestamp each pair of timestamps. The exact algorithm used is structurally + * identical to a merkle-mountain-range, although leaf sums aren't committed. + * As this function is under the consensus-critical core, it's guaranteed that + * the algorithm will not be changed in the future. + * + * @param timestamps a list of timestamps + * @return the timestamp for the tip of the tree. + */ + public static Timestamp makeMerkleTree(List timestamps) { + List stamps = timestamps; + Timestamp prevStamp = null; + boolean exit = false; + + while (!exit) { + if (stamps.size() > 0) { + prevStamp = stamps.get(0); + } + + List subStamps = stamps.subList(1, stamps.size()); + List nextStamps = new ArrayList<>(); + + for (Timestamp stamp : subStamps) { + if (prevStamp == null) { + prevStamp = stamp; + } else { + nextStamps.add(Merkle.catSha256(prevStamp, stamp)); + prevStamp = null; + } + } + + if (nextStamps.size() == 0) { + exit = true; + } else { + if (prevStamp != null) { + nextStamps.add(prevStamp); + } + + stamps = nextStamps; + } + } + + return prevStamp; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/OpenTimestamps.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/OpenTimestamps.java new file mode 100644 index 000000000..d422144ce --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/OpenTimestamps.java @@ -0,0 +1,484 @@ +package com.vitorpamplona.quartz.ots; + +import android.util.Log; + +import com.vitorpamplona.quartz.ots.attestation.BitcoinBlockHeaderAttestation; +import com.vitorpamplona.quartz.ots.attestation.EthereumBlockHeaderAttestation; +import com.vitorpamplona.quartz.ots.attestation.LitecoinBlockHeaderAttestation; +import com.vitorpamplona.quartz.ots.attestation.PendingAttestation; +import com.vitorpamplona.quartz.ots.attestation.TimeAttestation; +import com.vitorpamplona.quartz.encoders.Hex; +import com.vitorpamplona.quartz.ots.exceptions.VerificationException; +import com.vitorpamplona.quartz.ots.op.OpAppend; +import com.vitorpamplona.quartz.ots.op.OpCrypto; +import com.vitorpamplona.quartz.ots.op.OpSHA256; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * The main class for timestamp operations. + */ +public class OpenTimestamps { + + BitcoinExplorer explorer; + CalendarBuilder calBuilder; + + + public OpenTimestamps(BitcoinExplorer explorer, CalendarBuilder builder) { + this.explorer = explorer; + this.calBuilder = builder; + } + + /** + * Show information on a detached timestamp. + * + * @param detachedTimestampFile The DetachedTimestampFile ots. + * @return the string representation of the timestamp. + */ + public String info(DetachedTimestampFile detachedTimestampFile) { + return info(detachedTimestampFile, false); + } + + /** + * Show information on a detached timestamp with verbose option. + * + * @param detachedTimestampFile The DetachedTimestampFile ots. + * @param verbose Show verbose output. + * @return the string representation of the timestamp. + */ + public String info(DetachedTimestampFile detachedTimestampFile, boolean verbose) { + if (detachedTimestampFile == null) { + return "No ots file"; + } + + String fileHash = Utils.bytesToHex(detachedTimestampFile.timestamp.msg).toLowerCase(); + String hashOp = ((OpCrypto) detachedTimestampFile.fileHashOp)._TAG_NAME(); + + String firstLine = "File " + hashOp + " hash: " + fileHash + '\n'; + + return firstLine + "Timestamp:\n" + detachedTimestampFile.timestamp.strTree(0, verbose); + } + + /** + * Show information on a timestamp. + * + * @param timestamp The timestamp buffered. + * @return the string representation of the timestamp. + */ + public String info(Timestamp timestamp) { + if (timestamp == null) { + return "No timestamp"; + } + + String fileHash = Utils.bytesToHex(timestamp.msg).toLowerCase(); + String firstLine = "Hash: " + fileHash + '\n'; + + return firstLine + "Timestamp:\n" + timestamp.strTree(0); + } + + /** + * Create timestamp with the aid of a remote calendar. May be specified multiple times. + * + * @param fileTimestamp The Detached Timestamp File. + * @return The plain array buffer of stamped. + * @throws IOException if fileTimestamp is not valid, or the stamp procedure fails. + */ + public Timestamp stamp(DetachedTimestampFile fileTimestamp) throws IOException { + return stamp(fileTimestamp, null, 0); + } + + /** + * Create timestamp with the aid of a remote calendar. May be specified multiple times. + * + * @param fileTimestamp The timestamp to stamp. + * @param calendarsUrl The list of calendar urls. + * @param m The number of calendar to use. + * @return The plain array buffer of stamped. + * @throws IOException if fileTimestamp is not valid, or the stamp procedure fails. + */ + public Timestamp stamp(DetachedTimestampFile fileTimestamp, List calendarsUrl, Integer m) throws IOException { + List fileTimestamps = new ArrayList(); + fileTimestamps.add(fileTimestamp); + + return stamp(fileTimestamps, calendarsUrl, m); + } + + /** + * Create timestamp with the aid of a remote calendar. May be specified multiple times. + * + * @param fileTimestamps The list of timestamp to stamp. + * @param calendarsUrl The list of calendar urls. + * @param m The number of calendar to use. + * @return The plain array buffer of stamped. + * @throws IOException if fileTimestamp is not valid, or the stamp procedure fails. + */ + public Timestamp stamp(List fileTimestamps, List calendarsUrl, Integer m) throws IOException { + // Parse parameters + if (fileTimestamps == null || fileTimestamps.size() == 0) { + throw new IOException(); + } + + if (calendarsUrl == null || calendarsUrl.size() == 0) { + calendarsUrl = new ArrayList(); + calendarsUrl.add("https://alice.btc.calendar.opentimestamps.org"); + calendarsUrl.add("https://bob.btc.calendar.opentimestamps.org"); + calendarsUrl.add("https://finney.calendar.eternitywall.com"); + } + + if (m == null || m <= 0) { + if (calendarsUrl.size() == 0) { + m = 2; + } else if (calendarsUrl.size() == 1) { + m = 1; + } else { + m = calendarsUrl.size(); + } + } + + if (m < 0 || m > calendarsUrl.size()) { + Log.e("OpenTimestamp", "m cannot be greater than available calendar neither less or equal 0"); + throw new IOException(); + } + + // Build markle tree + Timestamp merkleTip = makeMerkleTree(fileTimestamps); + + if (merkleTip == null) { + throw new IOException(); + } + + // Stamping + Timestamp resultTimestamp = create(merkleTip, calendarsUrl, m); + + if (resultTimestamp == null) { + throw new IOException(); + } + + // Result of timestamp serialization + if (fileTimestamps.size() == 1) { + return fileTimestamps.get(0).timestamp; + } else { + return merkleTip; + } + } + + /** + * Create a timestamp + * + * @param timestamp The timestamp. + * @param calendarUrls List of calendar's to use. + * @param m Number of calendars to use. + * @return The created timestamp. + */ + private Timestamp create(Timestamp timestamp, List calendarUrls, Integer m) { + int capacity = calendarUrls.size(); + ExecutorService executor = Executors.newFixedThreadPool(4); + ArrayBlockingQueue> queue = new ArrayBlockingQueue<>(capacity); + + // Submit to all public calendars + for (final String calendarUrl : calendarUrls) { + Log.i("OpenTimestamps", "Submitting to remote calendar " + calendarUrl); + + try { + CalendarAsyncSubmit task = new CalendarAsyncSubmit(calendarUrl, timestamp.msg); + task.setQueue(queue); + executor.submit(task); + } catch (Exception e) { + e.printStackTrace(); + } + } + + int count = 0; + + for (count = 0; count < capacity && count < m; count++) { + try { + Optional optionalStamp = queue.take(); + + if (optionalStamp.isPresent()) { + try { + Timestamp time = optionalStamp.get(); + timestamp.merge(time); + Log.i("Open", ""+ timestamp.attestations.size()); + } catch (Exception e) { + e.printStackTrace(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (count < m) { + Log.e("OpenTimestamp", "Failed to create timestamp: requested " + String.valueOf(m) + " attestation" + ((m > 1) ? "s" : "") + " but received only " + String.valueOf(count)); + } + + //shut down the executor service now + executor.shutdown(); + + return timestamp; + } + + /** + * Make Merkle Tree of detached timestamps. + * + * @param fileTimestamps The list of DetachedTimestampFile. + * @return merkle tip timestamp. + */ + public Timestamp makeMerkleTree(List fileTimestamps) { + List merkleRoots = new ArrayList<>(); + + for (DetachedTimestampFile fileTimestamp : fileTimestamps) { + byte[] bytesRandom16 = new byte[16]; + + try { + bytesRandom16 = Utils.randBytes(16); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + + // nonce_appended_stamp = file_timestamp.timestamp.ops.add(com.vitorpamplona.quartz.ots.op.OpAppend(os.urandom(16))) + Timestamp nonceAppendedStamp = fileTimestamp.timestamp.add(new OpAppend(bytesRandom16)); + // merkle_root = nonce_appended_stamp.ops.add(com.vitorpamplona.quartz.ots.op.OpSHA256()) + Timestamp merkleRoot = nonceAppendedStamp.add(new OpSHA256()); + merkleRoots.add(merkleRoot); + } + + Timestamp merkleTip = Merkle.makeMerkleTree(merkleRoots); + return merkleTip; + } + + /** + * Compare and verify a detached timestamp. + * + * @param ots The DetachedTimestampFile containing the proof to verify. + * @param diggest The hash of the stamped file, in bytes + * @return Hashmap of block heights and timestamps indexed by chain: timestamp in seconds from 1 January 1970. + * @throws Exception if the verification procedure fails. + */ + + public HashMap verify(DetachedTimestampFile ots, byte[] diggest) throws Exception { + if (!Arrays.equals(ots.fileDigest(), diggest)) { + Log.e("OpenTimestamp", "Expected digest " + Hex.encode(ots.fileDigest()).toLowerCase()); + Log.e("OpenTimestamp", "File does not match original!"); + throw new Exception("File does not match original!"); + } + + return verify(ots.timestamp); + } + + /** + * Verify a timestamp. + * + * @param timestamp The timestamp. + * @return HashMap of block heights and timestamps indexed by chain: timestamp in seconds from 1 January 1970. + * @throws Exception if the verification procedure fails. + */ + public HashMap verify(Timestamp timestamp) throws Exception { + HashMap verifyResults = new HashMap<>(); + + for (Map.Entry item : timestamp.allAttestations().entrySet()) { + byte[] msg = item.getKey(); + TimeAttestation attestation = item.getValue(); + VerifyResult verifyResult = null; + VerifyResult.Chains chain = null; + + try { + if (attestation instanceof BitcoinBlockHeaderAttestation) { + chain = VerifyResult.Chains.BITCOIN; + Long time = verify((BitcoinBlockHeaderAttestation) attestation, msg); + int height = ((BitcoinBlockHeaderAttestation) attestation).getHeight(); + verifyResult = new VerifyResult(time, height); + } else if (attestation instanceof LitecoinBlockHeaderAttestation) { + chain = VerifyResult.Chains.LITECOIN; + Long time = verify((LitecoinBlockHeaderAttestation) attestation, msg); + int height = ((LitecoinBlockHeaderAttestation) attestation).getHeight(); + verifyResult = new VerifyResult(time, height); + } + + if (verifyResult != null && verifyResults.containsKey(chain)) { + if (verifyResult.height < verifyResults.get(chain).height) { + verifyResults.put(chain, verifyResult); + } + } + + if (verifyResult != null && !verifyResults.containsKey(chain)) { + verifyResults.put(chain, verifyResult); + } + } catch (VerificationException e) { + throw e; + } catch (Exception e) { + String text = ""; + + if (chain == VerifyResult.Chains.BITCOIN) { + text = BitcoinBlockHeaderAttestation.chain; + } else if (chain == VerifyResult.Chains.LITECOIN) { + text = LitecoinBlockHeaderAttestation.chain; + } else if (chain == VerifyResult.Chains.ETHEREUM) { + text = EthereumBlockHeaderAttestation.chain; + } else { + throw e; + } + + Log.e("OpenTimestamp", Utils.toUpperFirstLetter(text) + " verification failed: " + e.getMessage()); + throw e; + } + } + return verifyResults; + } + + /** + * Verify an Bitcoin Block Header Attestation. + * if the node is not reachable or it fails, uses Lite-client verification. + * + * @param attestation The BitcoinBlockHeaderAttestation attestation. + * @param msg The digest to verify. + * @return The unix timestamp in seconds from 1 January 1970. + * @throws VerificationException if it doesn't check the merkle root of the block. + * @throws Exception if the verification procedure fails. + */ + public Long verify(BitcoinBlockHeaderAttestation attestation, byte[] msg) throws VerificationException, Exception { + Integer height = attestation.getHeight(); + BlockHeader blockInfo; + + try { + String blockHash = explorer.blockHash(height); + blockInfo = explorer.block(blockHash); + Log.i("OpenTimestamps", "Lite-client verification, assuming block " + blockHash + " is valid"); + } catch (Exception e2) { + e2.printStackTrace(); + throw e2; + } + + return attestation.verifyAgainstBlockheader(Utils.arrayReverse(msg), blockInfo); + } + + /** + * Verify an Litecoin Block Header Attestation. Litecoin verification uses only lite-client verification. + * + * @param attestation The LitecoinBlockHeaderAttestation attestation. + * @param msg The digest to verify. + * @return The unix timestamp in seconds from 1 January 1970. + * @throws VerificationException if it doesn't check the merkle root of the block. + * @throws Exception if the verification procedure fails. + */ + public Long verify(LitecoinBlockHeaderAttestation attestation, byte[] msg) throws VerificationException, Exception { + Integer height = attestation.getHeight(); + BlockHeader blockInfo; + + try { + String blockHash = explorer.blockHash(height); + blockInfo = explorer.block(blockHash); + Log.i("OpenTimestamps", "Lite-client verification, assuming block " + blockHash + " is valid"); + } catch (Exception e2) { + e2.printStackTrace(); + throw e2; + } + + return attestation.verifyAgainstBlockheader(Utils.arrayReverse(msg), blockInfo); + } + + /** + * Upgrade a timestamp. + * + * @param detachedTimestamp The DetachedTimestampFile containing the proof to verify. + * @return a boolean representing if the timestamp has changed. + * @throws Exception if the upgrading procedure fails. + */ + public boolean upgrade(DetachedTimestampFile detachedTimestamp) throws Exception { + // Upgrade timestamp + boolean changed = upgrade(detachedTimestamp.timestamp); + return changed; + } + + /** + * Attempt to upgrade an incomplete timestamp to make it verifiable. + * Note that this means if the timestamp that is already complete, False will be returned as nothing has changed. + * + * @param timestamp The timestamp to upgrade. + * @return a boolean representing if the timestamp has changed. + * @throws Exception if the upgrading procedure fails. + */ + public boolean upgrade(Timestamp timestamp) throws Exception { + // Check remote calendars for upgrades. + // This time we only check PendingAttestations - we can't be as agressive. + + boolean upgraded = false; + Set existingAttestations = timestamp.getAttestations(); + + for (Timestamp subStamp : timestamp.directlyVerified()) { + for (TimeAttestation attestation : subStamp.attestations) { + if (attestation instanceof PendingAttestation && !subStamp.isTimestampComplete()) { + String calendarUrl = new String(((PendingAttestation) attestation).getUri(), StandardCharsets.UTF_8); + // var calendarUrl = calendarUrls[0]; + byte[] commitment = subStamp.msg; + + try { + Calendar calendar = new Calendar(calendarUrl); + Timestamp upgradedStamp = upgrade(subStamp, calendar, commitment, existingAttestations); + + try { + subStamp.merge(upgradedStamp); + } catch (Exception e) { + e.printStackTrace(); + } + + upgraded = true; + } catch (Exception e) { + Log.i("OpenTimestamps", e.getMessage()); + } + } + } + } + + return upgraded; + } + + private Timestamp upgrade(Timestamp subStamp, Calendar calendar, byte[] commitment, Set existingAttestations) throws Exception { + Timestamp upgradedStamp; + + try { + upgradedStamp = calendar.getTimestamp(commitment); + + if (upgradedStamp == null) { + throw new Exception("Invalid stamp"); + } + } catch (Exception e) { + Log.i("OpenTimestamps", "Calendar " + calendar.getUrl() + ": " + e.getMessage()); + throw e; + } + + Set attsFromRemote = upgradedStamp.getAttestations(); + + if (attsFromRemote.size() > 0) { + Log.i("OpenTimestamps", "Got 1 attestation(s) from " + calendar.getUrl()); + } + + // Set difference from remote attestations & existing attestations + Set newAttestations = attsFromRemote; + newAttestations.removeAll(existingAttestations); + + // changed & found_new_attestations + // foundNewAttestations = true; + // Log.i("OpenTimestamps", attsFromRemote.size + ' attestation(s) from ' + calendar.url); + + // Set union of existingAttestations & newAttestations + existingAttestations.addAll(newAttestations); + + return upgradedStamp; + // subStamp.merge(upgradedStamp); + // args.cache.merge(upgraded_stamp) + // sub_stamp.merge(upgraded_stamp) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/README.md b/quartz/src/main/java/com/vitorpamplona/quartz/ots/README.md new file mode 100644 index 000000000..5b2bc9521 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/README.md @@ -0,0 +1,21 @@ +This code came from https://github.com/opentimestamps/java-opentimestamps + +And includes modifications to +1 - Avoid dependencies that do not work on Android +2 - Move from org.json to jackson +3 - Move from basic Url connection to OkHttp (and obey Tor settings) +4 - Generalize the use of Blockstream as a Bitcoin Block explorer. + +— + +Original License + +The OpenTimestamps Client is free software: you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public License as published +by the Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +The OpenTimestamps Client is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +below for more details. \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/StreamDeserializationContext.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/StreamDeserializationContext.java new file mode 100644 index 000000000..b566d816f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/StreamDeserializationContext.java @@ -0,0 +1,107 @@ +package com.vitorpamplona.quartz.ots; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.Arrays; +import java.util.logging.Logger; + +public class StreamDeserializationContext { + + byte[] buffer; + int counter = 0; + + public StreamDeserializationContext(byte[] stream) { + this.buffer = stream; + this.counter = 0; + } + + public byte[] getOutput() { + return this.buffer; + } + + public int getCounter() { + return this.counter; + } + + public byte[] read(int l) { + if (this.counter == this.buffer.length) { + return null; + } + + if (l+this.counter > this.buffer.length) { + l = this.buffer.length-this.counter; + } + + // const uint8Array = new Uint8Array(this.buffer,this.counter,l); + byte[] uint8Array = Arrays.copyOfRange(this.buffer, this.counter, this.counter + l); + this.counter += l; + + return uint8Array; + } + + public boolean readBool() { + byte b = this.read(1)[0]; + + if (b == 0xff) { + return true; + } else if (b == 0x00) { + return false; + } + + return false; + } + + public int readVaruint() { + int value = 0; + byte shift = 0; + byte b; + + do { + b = this.read(1)[0]; + value |= (b & 0b01111111) << shift; + shift += 7; + } while ((b & 0b10000000) != 0b00000000); + + return value; + } + + public byte[] readBytes(int expectedLength) throws DeserializationException { + + + if (expectedLength == 0) { + return this.readVarbytes(1024, 0); + } + + return this.read(expectedLength); + } + + public byte[] readVarbytes(int maxLen) throws DeserializationException { + return readVarbytes(maxLen, 0); + } + + public byte[] readVarbytes(int maxLen, int minLen) throws DeserializationException { + int l = this.readVaruint(); + + if (l > maxLen) { + throw new DeserializationException("varbytes max length exceeded;"); + } else if (l < minLen) { + throw new DeserializationException("varbytes min length not met;"); + } + + return this.read(l); + } + + public boolean assertMagic(byte[] expectedMagic) { + byte[] actualMagic = this.read(expectedMagic.length); + + return Arrays.equals(expectedMagic, actualMagic); + } + + public boolean assertEof() { + byte[] excess = this.read(1); + return excess != null; + } + + public String toString() { + return Arrays.toString(this.buffer); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/StreamSerializationContext.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/StreamSerializationContext.java new file mode 100644 index 000000000..87368bda8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/StreamSerializationContext.java @@ -0,0 +1,82 @@ +package com.vitorpamplona.quartz.ots; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; + +public class StreamSerializationContext { + + List buffer = new ArrayList<>(); + + public StreamSerializationContext() { + this.buffer = new ArrayList<>(); + } + + public byte[] getOutput() { + byte[] bytes = new byte[this.buffer.size()]; + + for (int i = 0; i < this.buffer.size(); i++) { + bytes[i] = this.buffer.get(i); + } + + return bytes; + } + + public int getLength() { + return this.buffer.size(); + } + + public void writeBool(boolean value) { + if (value) { + this.writeByte((byte) 0xff); + } else { + this.writeByte((byte) 0x00); + } + } + + public void writeVaruint(int value) { + if ((value) == 0b00000000) { + this.writeByte((byte) 0x00); + } else { + while (value != 0) { + byte b = (byte) ((value & 0xff) & 0b01111111); + + if ((value) > 0b01111111) { + b |= 0b10000000; + } + + this.writeByte(b); + + if ((value) <= 0b01111111) { + break; + } + + value = value >> 7; + } + } + } + + public void writeByte(byte value) { + this.buffer.add(Byte.valueOf(value)); + } + + public void writeByte(Byte value) { + this.buffer.add(value); + } + + public void writeBytes(byte[] value) { + for (byte b : value) { + this.writeByte(b); + } + } + + public void writeVarbytes(byte[] value) { + this.writeVaruint(value.length); + this.writeBytes(value); + } + + public String toString() { + return Arrays.toString(this.getOutput()); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/Timestamp.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Timestamp.java new file mode 100644 index 000000000..f9f7d03c6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Timestamp.java @@ -0,0 +1,617 @@ +package com.vitorpamplona.quartz.ots; + +import com.vitorpamplona.quartz.ots.attestation.BitcoinBlockHeaderAttestation; +import com.vitorpamplona.quartz.ots.attestation.TimeAttestation; +import com.vitorpamplona.quartz.encoders.Hex; +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import com.vitorpamplona.quartz.ots.op.Op; +import com.vitorpamplona.quartz.ots.op.OpBinary; +import com.vitorpamplona.quartz.ots.op.OpSHA256; + +import java.util.*; +import java.util.Map.Entry; + +/** + * Proof that one or more attestations commit to a message. + * The proof is in the form of a tree, with each node being a message, and the + * edges being operations acting on those messages. The leafs of the tree are + * attestations that attest to the time that messages in the tree existed prior. + */ +public class Timestamp { + + public byte[] msg; + public List attestations; + public HashMap ops; + + /** + * Create a com.vitorpamplona.quartz.ots.Timestamp object. + * + * @param msg - Desc + */ + public Timestamp(byte[] msg) { + this.msg = msg; + this.attestations = new ArrayList<>(); + this.ops = new HashMap<>(); + } + + /** + * Deserialize a Timestamp. + * + * @param ots - The serialized byte array. + * @param initialMsg - The initial message. + * @return The deserialized Timestamp. + */ + public static Timestamp deserialize(byte[] ots, byte[] initialMsg) throws DeserializationException { + StreamDeserializationContext ctx = new StreamDeserializationContext(ots); + + return Timestamp.deserialize(ctx, initialMsg); + } + + /** + * Deserialize a Timestamp. + * Because the serialization format doesn't include the message that the + * timestamp operates on, you have to provide it so that the correct + * operation results can be calculated. + * The message you provide is assumed to be correct; if it causes a op to + * raise MsgValueError when the results are being calculated (done + * immediately, not lazily) DeserializationError is raised instead. + * + * @param ctx - The stream deserialization context. + * @param initialMsg - The initial message. + * @return The deserialized Timestamp. + */ + public static Timestamp deserialize(StreamDeserializationContext ctx, byte[] initialMsg) + throws DeserializationException { + Timestamp self = new Timestamp(initialMsg); + byte tag = ctx.readBytes(1)[0]; + + while ((tag & 0xff) == 0xff) { + byte current = ctx.readBytes(1)[0]; + doTagOrAttestation(self, ctx, current, initialMsg); + tag = ctx.readBytes(1)[0]; + } + + doTagOrAttestation(self, ctx, tag, initialMsg); + + return self; + } + + private static void doTagOrAttestation(Timestamp self, StreamDeserializationContext ctx, byte tag, byte[] initialMsg) + throws DeserializationException { + if ((tag & 0xff) == 0x00) { + TimeAttestation attestation = TimeAttestation.deserialize(ctx); + self.attestations.add(attestation); + } else { + Op op = Op.deserializeFromTag(ctx, tag); + byte[] result = op.call(initialMsg); + + Timestamp stamp = Timestamp.deserialize(ctx, result); + self.ops.put(op, stamp); + } + } + + /** + * Create a Serialize object. + * + * @return The byte array of the serialized timestamp + */ + public byte[] serialize() { + StreamSerializationContext ctx = new StreamSerializationContext(); + serialize(ctx); + + return ctx.getOutput(); + } + + /** + * Create a Serialize object. + * + * @param ctx - The stream serialization context. + */ + public void serialize(StreamSerializationContext ctx) { + List sortedAttestations = this.attestations; // TODO: Hm, this is just a reference copy... + Collections.sort(sortedAttestations); + + if (sortedAttestations.size() > 1) { + for (int i = 0; i < sortedAttestations.size() - 1; i++) { + ctx.writeBytes(new byte[]{(byte) 0xff, (byte) 0x00}); + sortedAttestations.get(i).serialize(ctx); + } + } + + if (this.ops.isEmpty()) { + ctx.writeByte((byte) 0x00); + + if (!sortedAttestations.isEmpty()) { + sortedAttestations.get(sortedAttestations.size() - 1).serialize(ctx); + } + } else if (!this.ops.isEmpty()) { + if (!sortedAttestations.isEmpty()) { + ctx.writeBytes(new byte[]{(byte) 0xff, (byte) 0x00}); + sortedAttestations.get(sortedAttestations.size() - 1).serialize(ctx); + } + + int counter = 0; + List> list = sortToList(this.ops.entrySet()); + + for (Map.Entry entry : list) { + Timestamp stamp = entry.getValue(); + Op op = entry.getKey(); + + if (counter < this.ops.size() - 1) { + ctx.writeBytes(new byte[]{(byte) 0xff}); + counter++; + } + + op.serialize(ctx); + stamp.serialize(ctx); + } + } + } + + /** + * Add all operations and attestations from another timestamp to this one. + * + * @param other - Initial other com.vitorpamplona.quartz.ots.Timestamp to merge. + * @throws Exception different timestamps messages + */ + public void merge(Timestamp other) throws Exception { + if (!Arrays.equals(this.msg, other.msg)) { + //Log.e("OpenTimestamp", "Can\'t merge timestamps for different messages together"); + throw new Exception("Can\'t merge timestamps for different messages together"); + } + + for (final TimeAttestation attestation : other.attestations) { + this.attestations.add(attestation); + } + + for (Map.Entry entry : other.ops.entrySet()) { + Timestamp otherOpStamp = entry.getValue(); + Op otherOp = entry.getKey(); + + Timestamp ourOpStamp = this.ops.get(otherOp); + + if (ourOpStamp == null) { + ourOpStamp = new Timestamp(otherOp.call(this.msg)); + this.ops.put(otherOp, ourOpStamp); + } + + ourOpStamp.merge(otherOpStamp); + } + } + + /** + * Shrink Timestamp. + * Remove useless pending attestions if exist a full bitcoin attestation. + * + * @return TimeAttestation - the minimal attestation. + * @throws Exception no attestion founds. + */ + public TimeAttestation shrink() throws Exception { + // Get all attestations + HashMap allAttestations = this.allAttestations(); + + if (allAttestations.size() == 0) { + throw new Exception(); + } else if (allAttestations.size() == 1) { + return allAttestations.values().iterator().next(); + } else if (this.ops.size() == 0) { + throw new Exception(); // TODO: Need a descriptive exception string here + } + + // Fore >1 attestations : + // Search first BitcoinBlockHeaderAttestation + TimeAttestation minAttestation = null; + + for (Map.Entry entry : this.ops.entrySet()) { + Timestamp timestamp = entry.getValue(); + //Op op = entry.getKey(); + + for (TimeAttestation attestation : timestamp.getAttestations()) { + if (attestation instanceof BitcoinBlockHeaderAttestation) { + if (minAttestation == null) { + minAttestation = attestation; + } else { + if (minAttestation instanceof BitcoinBlockHeaderAttestation + && attestation instanceof BitcoinBlockHeaderAttestation + && ((BitcoinBlockHeaderAttestation) minAttestation).getHeight() + > ((BitcoinBlockHeaderAttestation) attestation).getHeight()) { + minAttestation = attestation; + } + } + } + } + } + + // Only pending attestations : return the first + if (minAttestation == null) { + return allAttestations.values().iterator().next(); + } + + // Remove attestation if not min attestation + boolean shrinked = false; + + for (Iterator> it = this.ops.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + Timestamp timestamp = entry.getValue(); + Op op = entry.getKey(); + Set attestations = timestamp.getAttestations(); + + if (attestations.size() > 0 && attestations.contains(minAttestation) && shrinked == false) { + timestamp.shrink(); + shrinked = true; + } else { + it.remove(); + } + } + + return minAttestation; + } + + /** + * Return the digest of the timestamp. + * + * @return The byte[] digest string. + */ + public byte[] getDigest() { + return this.msg; + } + + /** + * Return as memory hierarchical object. + * + * @param indent - Initial hierarchical indention. + * @return The output string. + */ + public String toString(int indent) { + StringBuilder builder = new StringBuilder(); + builder.append(Timestamp.indention(indent) + "msg: " + Hex.encode(this.msg) + "\n"); + builder.append(Timestamp.indention(indent) + this.attestations.size() + " attestations: \n"); + int i = 0; + + for (final TimeAttestation attestation : this.attestations) { + builder.append(Timestamp.indention(indent) + "[" + i + "] " + attestation.toString() + "\n"); + i++; + } + + i = 0; + builder.append(Timestamp.indention(indent) + this.ops.size() + " ops: \n"); + + for (Map.Entry entry : this.ops.entrySet()) { + Timestamp stamp = entry.getValue(); + Op op = entry.getKey(); + + builder.append(Timestamp.indention(indent) + "[" + i + "] op: " + op.toString() + "\n"); + builder.append(Timestamp.indention(indent) + "[" + i + "] timestamp: \n"); + builder.append(stamp.toString(indent + 1)); + i++; + } + + builder.append('\n'); + + return builder.toString(); + } + + /** + * Indention function for printing tree. + * + * @param pos - Initial hierarchical indention. + * @return The output space string. + */ + public static String indention(int pos) { + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < pos; i++) { + builder.append(" "); + } + + return builder.toString(); + } + + public String strTree(int indent) { + return strTree(indent, false); + } + + private String strResult(boolean verbosity, byte[] parameter, byte[] result) { + final String ANSI_HEADER = "\u001B[95m"; + final String ANSI_OKBLUE = "\u001B[94m"; + final String ANSI_OKGREEN = "\u001B[92m"; + final String ANSI_WARNING = "\u001B[93m"; + final String ANSI_FAIL = "\u001B[91m"; + final String ANSI_ENDC = "\u001B[0m"; + final String ANSI_BOLD = "\u001B[1m"; + final String ANSI_UNDERLINE = "\u001B[4m"; + + String rr = ""; + + if (verbosity == true && result != null) { + rr += " == "; + String resultHex = Utils.bytesToHex(result); + + if (parameter == null) { + rr += resultHex; + } else { + String parameterHex = Utils.bytesToHex(parameter); + + try { + int index = resultHex.indexOf(parameterHex); + String parameterHexHighlight = ANSI_BOLD + parameterHex + ANSI_ENDC; + + if (index == 0) { + rr += parameterHexHighlight + resultHex.substring(index + parameterHex.length(), resultHex.length()); + } else { + rr += resultHex.substring(0, index) + parameterHexHighlight; + } + } catch (Exception err) { + rr += resultHex; + } + } + } + + return rr; + } + + /** + * Return as tree hierarchical object. + * + * @param indent - Initial hierarchical indention. + * @param verbosity - Verbose option. + * @return The output string. + */ + public String strTree(int indent, boolean verbosity) { + StringBuilder builder = new StringBuilder(); + + if (!this.attestations.isEmpty()) { + for (final TimeAttestation attestation : this.attestations) { + builder.append(Timestamp.indention(indent)); + builder.append("verify " + attestation.toString() + strResult(verbosity, this.msg, null) + "\n"); + + if (attestation instanceof BitcoinBlockHeaderAttestation) { + String tx = Utils.bytesToHex(Utils.arrayReverse(this.msg)); + builder.append(Timestamp.indention(indent) + "# Bitcoin block merkle root " + tx.toLowerCase() + "\n"); + } + } + } + + if (this.ops.size() > 1) { + TreeMap ordered = new TreeMap<>(this.ops); + + for (Map.Entry entry : ordered.entrySet()) { + Timestamp timestamp = entry.getValue(); + Op op = entry.getKey(); + + byte[] curRes = op.call(this.msg); + byte[] curPar = null; + + if (op instanceof OpBinary) { + curPar = ((OpBinary) op).arg; + } + + builder.append(Timestamp.indention(indent) + " -> " + op.toString().toLowerCase() + strResult(verbosity, curPar, curRes).toLowerCase() + "\n"); + builder.append(timestamp.strTree(indent + 1, verbosity)); + } + } else if (this.ops.size() > 0) { + // output += com.eternitywall.ots.Timestamp.indention(indent); + for (Map.Entry entry : this.ops.entrySet()) { + Timestamp timestamp = entry.getValue(); + Op op = entry.getKey(); + + byte[] curRes = op.call(this.msg); + byte[] curPar = null; + + if (op instanceof OpBinary) { + curPar = ((OpBinary) op).arg; + } + + builder.append(Timestamp.indention(indent) + op.toString().toLowerCase() + strResult(verbosity, curPar, curRes).toLowerCase() + "\n"); + builder.append(timestamp.strTree(indent, verbosity)); + } + } + + return builder.toString(); + } + + /** + * Returns a list of all sub timestamps with attestations. + * + * @return List of all sub timestamps with attestations. + */ + public List directlyVerified() { + if (!this.attestations.isEmpty()) { + List list = new ArrayList<>(); + list.add(this); + return list; + } + + List list = new ArrayList<>(); + + for (Map.Entry entry : this.ops.entrySet()) { + Timestamp ts = entry.getValue(); + //Op op = entry.getKey(); + + List result = ts.directlyVerified(); + list.addAll(result); + } + + return list; + } + + /** + * Returns a set of all Attestations. + * + * @return Set of all timestamp attestations. + */ + public Set getAttestations() { + Set set = new HashSet(); + + for (Map.Entry item : this.allAttestations().entrySet()) { + //byte[] msg = item.getKey(); + TimeAttestation attestation = item.getValue(); + set.add(attestation); + } + + return set; + } + + /** + * Determine if timestamp is complete and can be verified. + * + * @return True if the timestamp is complete, False otherwise. + */ + public Boolean isTimestampComplete() { + for (Map.Entry item : this.allAttestations().entrySet()) { + //byte[] msg = item.getKey(); + TimeAttestation attestation = item.getValue(); + + if (attestation instanceof BitcoinBlockHeaderAttestation) { + return true; + } + } + + return false; + } + + /** + * Iterate over all attestations recursively + * + * @return Returns iterable of (msg, attestation) + */ + public HashMap allAttestations() { + HashMap map = new HashMap<>(); + + for (TimeAttestation attestation : this.attestations) { + map.put(this.msg, attestation); + } + + for (Map.Entry entry : this.ops.entrySet()) { + Timestamp ts = entry.getValue(); + //Op op = entry.getKey(); + + HashMap subMap = ts.allAttestations(); + + for (Map.Entry item : subMap.entrySet()) { + byte[] msg = item.getKey(); + TimeAttestation attestation = item.getValue(); + map.put(msg, attestation); + } + } + + return map; + } + + /** + * Iterate over all tips recursively + * + * @return Returns iterable of (msg, attestation) + */ + public Set allTips() { + Set set = new HashSet<>(); + + if (this.ops.size() == 0) { + set.add(this.msg); + } + + for (Map.Entry entry : this.ops.entrySet()) { + Timestamp ts = entry.getValue(); + //Op op = entry.getKey(); + + Set subSet = ts.allTips(); + + for (byte[] msg : subSet) { + set.add(msg); + } + } + + return set; + } + + /** + * Compare timestamps. + * + * @param timestamp the timestamp to compare with + * @return Returns true if timestamps are equals + */ + public boolean equals(Timestamp timestamp) { + if (Arrays.equals(this.getDigest(), timestamp.getDigest()) == false) { + return false; + } + + // Check attestations + if (this.attestations.size() != timestamp.attestations.size()) { + return false; + } + + for (int i = 0; i < this.attestations.size(); i++) { + TimeAttestation ta1 = this.attestations.get(i); + TimeAttestation ta2 = timestamp.attestations.get(i); + + if (!(ta1.equals(ta2))) { + return false; + } + } + + // Check operations + if (this.ops.size() != timestamp.ops.size()) { + return false; + } + + // Order list of operations + List> list1 = sortToList(this.ops.entrySet()); + List> list2 = sortToList(timestamp.ops.entrySet()); + + for (int i = 0; i < list1.size(); i++) { + Op op1 = list1.get(i).getKey(); + Op op2 = list2.get(i).getKey(); + + if (!op1.equals(op2)) { + return false; + } + + Timestamp t1 = list1.get(i).getValue(); + Timestamp t2 = list2.get(i).getValue(); + + if (!t1.equals(t2)) { + return false; + } + } + + return true; + } + + /** + * Add Op to current timestamp and return the sub stamp + * + * @param op - The operation to insert + * @return Returns the sub timestamp + */ + public Timestamp add(Op op) { + // nonce_appended_stamp = timestamp.ops.add(com.vitorpamplona.quartz.ots.op.OpAppend(os.urandom(16))) + //Op opAppend = new OpAppend(bytes); + + if (this.ops.containsKey(op)) { + return this.ops.get(op); + } + + Timestamp stamp = new Timestamp(op.call(this.msg)); + this.ops.put(op, stamp); + + return stamp; + } + + /** + * Retrieve a sorted list of all map entries. + * + * @param setEntries - The entries set of ops hashmap + * @return Returns the sorted list of map entries + */ + public List> sortToList(Set> setEntries) { + List> entries = new ArrayList<>(setEntries); + Collections.sort(entries, new Comparator>() { + @Override + public int compare(Entry a, Entry b) { + return a.getKey().compareTo(b.getKey()); + } + }); + + return entries; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/Utils.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Utils.java new file mode 100644 index 000000000..85cb300e8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/Utils.java @@ -0,0 +1,204 @@ +package com.vitorpamplona.quartz.ots; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.logging.ConsoleHandler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + * Utility functions for (mostly) manipulating byte arrays. + */ +public class Utils { + /** + * Fills a byte array with the given byte value. + * + * @param array the array to fill + * @param value the value to fill the array with + */ + public static void arrayFill(byte[] array, byte value) { + for (int i = 0; i < array.length; i++) { + array[i] = value; + } + } + + /** + * Returns the first value that is not null. If all objects are null, then it returns null. + * + * @param items the array of Ts + * @param This describes my type parameter + * @return the first value that is not null. If all objects are null, then it returns null. + * @deprecated Not used by Java OpenTimestamps itself, and doesn't offer much useful functionality. + */ + @Deprecated + public static T coalesce(T... items) { + for (T i : items) { + if (i != null) { + return i; + } + } + + return null; + } + + /** + * Returns a copy of the byte array argument, or null if the byte array argument is null. + * + * @param data the array of bytes to copy + * @return the copied byte array + */ + public static byte[] arraysCopy(byte[] data) { + if (data == null) { + return null; + } + + byte[] copy = new byte[data.length]; + System.arraycopy(data, 0, copy, 0, data.length); + + return copy; + } + + /** + * Returns a byte array which is the result of concatenating the two passed in byte arrays. + * None of the passed in arrays may be null. + * + * @param array1 the first array of bytes + * @param array2 the second array of bytes + * @return a copy of array1 and array2 concatenated together + */ + public static byte[] arraysConcat(byte[] array1, byte[] array2) { + byte[] array1and2 = new byte[array1.length + array2.length]; + System.arraycopy(array1, 0, array1and2, 0, array1.length); + System.arraycopy(array2, 0, array1and2, array1.length, array2.length); + + return array1and2; + } + + /** + * Returns a given length array of random bytes. + * + * @param length the requested length of the byte array + * @return a given length array of random bytes + * @throws NoSuchAlgorithmException for Java 8 implementations + */ + public static byte[] randBytes(int length) throws NoSuchAlgorithmException { + //Java 6 & 7: + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[length]; + random.nextBytes(bytes); + + // Java 8 (even more secure): + // SecureRandom.getInstanceStrong().nextBytes(bytes); + + return bytes; + } + + /** + * Returns a reversed copy of the passed in byte array. + * + * @param array the byte array to reverse + * @return a copy of the byte array, reversed + */ + public static byte[] arrayReverse(byte[] array) { + byte[] reversedArray = new byte[array.length]; + + for (int i = array.length - 1, j = 0; i >= 0; i--, j++) { + reversedArray[j] = array[i]; + } + + return reversedArray; + } + + /** + * Compares two byte arrays. + * + * @param left the left byte array to compare with + * @param right the right byte array to compare with + * @return 0 if the arrays are identical, negative if left < right, positive if left > right + */ + public static int compare(byte[] left, byte[] right) { + for (int i = 0, j = 0; i < left.length && j < right.length; i++, j++) { + int a = (left[i] & 0xff); + int b = (right[j] & 0xff); + + if (a != b) { + return a - b; + } + } + + return left.length - right.length; + } + + /** + * Returns a HEX representation of the passed in byte array. + * + * @param bytes the byte array to convert into a string of HEX + * @return a string representation of the byte array + */ + public static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + + return sb.toString(); + } + + /** + * Converts a HEX representation to its byte array representation. The passed in string must not be null. + * + * @param s a string in HEX format to be converted to a corresponding byte array + * @return the byte array representation of the string in HEX format + * @throws IllegalArgumentException if the passed in HEX string can't be converted to a byte array + */ + public static byte[] hexToBytes(String s) throws IllegalArgumentException { + int len = s.length(); + + if (len % 2 == 1) { + throw new IllegalArgumentException(); + } + + byte[] data = new byte[len / 2]; + + for (int i = 0; i < len; i += 2) { + if ((Character.digit(s.charAt(i), 16) == -1) || (Character.digit(s.charAt(i + 1), 16) == -1)) { + throw new IllegalArgumentException(); + } + + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + + Character.digit(s.charAt(i + 1), 16)); + } + + return data; + } + + /** + * Returns a string with the first letter uppercase. + * + * @param string the string to get its first character converted to uppercase + * @return the string, with its first character converted to uppercase + */ + public static String toUpperFirstLetter(String string) { + return string.substring(0, 1).toUpperCase() + string.substring(1).toLowerCase(); + } + + // TODO: This is not the way to do logging. Fix later, possibly with slf4j annotation. Need to read up on the subject. + public static Logger getLogger(String name) { + Logger log = Logger.getLogger(name); + ConsoleHandler handler = new ConsoleHandler(); + + handler.setFormatter(new SimpleFormatter() { + @Override + public synchronized String format(LogRecord lr) { + return lr.getMessage() + "\r\n"; + } + }); + + log.setUseParentHandlers(false); + log.addHandler(handler); + + return log; + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/VerifyResult.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/VerifyResult.java new file mode 100644 index 000000000..64da0569a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/VerifyResult.java @@ -0,0 +1,56 @@ +package com.vitorpamplona.quartz.ots; + +import java.text.DateFormatSymbols; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Class that lets us compare, sort, store and print timestamps. + */ +public class VerifyResult implements Comparable { + public static enum Chains { + BITCOIN, LITECOIN, ETHEREUM + } + + public Long timestamp; + public int height; + + public VerifyResult(Long timestamp, int height) { + this.timestamp = timestamp; + this.height = height; + } + + /** + * Returns, if existing, a string representation describing the existence of a block attest + */ + public String toString() { + if (height == 0 || timestamp == null) { + return ""; + } + + String pattern = "YYYY-MM-dd z"; + Locale locale = new Locale("en", "UK"); + DateFormatSymbols dateFormatSymbols = new DateFormatSymbols(locale); + SimpleDateFormat simpleDateFormat = new SimpleDateFormat(pattern, dateFormatSymbols); + String string = simpleDateFormat.format(new Date(timestamp * 1000)); + + return "block " + String.valueOf(height) + " attests data existed as of " + string; + } + + @Override + public int compareTo(VerifyResult vr) { + return this.height - vr.height; + } + + @Override + public boolean equals(Object obj) { + VerifyResult vr = (VerifyResult) obj; + return this.timestamp == vr.timestamp && this.height == vr.height; + } + + @Override + public int hashCode() { + return ((int) (long) (this.timestamp)) ^ this.height; + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/BitcoinBlockHeaderAttestation.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/BitcoinBlockHeaderAttestation.java new file mode 100644 index 000000000..55acbefe6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/BitcoinBlockHeaderAttestation.java @@ -0,0 +1,118 @@ +package com.vitorpamplona.quartz.ots.attestation; + +import com.vitorpamplona.quartz.ots.BlockHeader; +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; +import com.vitorpamplona.quartz.ots.exceptions.VerificationException; + +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Bitcoin Block Header Attestation. + * The commitment digest will be the merkleroot of the blockheader. + * The block height is recorded so that looking up the correct block header in + * an external block header database doesn't require every header to be stored + * locally (33MB and counting). (remember that a memory-constrained local + * client can save an MMR that commits to all blocks, and use an external service to fill + * in pruned details). + * Otherwise no additional redundant data about the block header is recorded. + * This is very intentional: since the attestation contains (nearly) the + * absolute bare minimum amount of data, we encourage implementations to do + * the correct thing and get the block header from a by-height index, check + * that the merkleroots match, and then calculate the time from the header + * information. Providing more data would encourage implementations to cheat. + * Remember that the only thing that would invalidate the block height is a + * reorg, but in the event of a reorg the merkleroot will be invalid anyway, + * so there's no point to recording data in the attestation like the header + * itself. At best that would just give us extra confirmation that a reorg + * made the attestation invalid; reorgs deep enough to invalidate timestamps are + * exceptionally rare events anyway, so better to just tell the user the timestamp + * can't be verified rather than add almost-never tested code to handle that case + * more gracefully. + * + * @see TimeAttestation + */ +public class BitcoinBlockHeaderAttestation extends TimeAttestation { + + public static byte[] _TAG = {(byte) 0x05, (byte) 0x88, (byte) 0x96, (byte) 0x0d, (byte) 0x73, (byte) 0xd7, (byte) 0x19, (byte) 0x01}; + public static String chain = "bitcoin"; + + @Override + public byte[] _TAG() { + return BitcoinBlockHeaderAttestation._TAG; + } + + private int height = 0; + + public int getHeight() { + return height; + } + + public BitcoinBlockHeaderAttestation(int height_) { + super(); + this.height = height_; + } + + public static BitcoinBlockHeaderAttestation deserialize(StreamDeserializationContext ctxPayload) { + int height = ctxPayload.readVaruint(); + + return new BitcoinBlockHeaderAttestation(height); + } + + @Override + public void serializePayload(StreamSerializationContext ctx) { + ctx.writeVaruint(this.height); + } + + public String toString() { + return "BitcoinBlockHeaderAttestation(" + this.height + ")"; + } + + @Override + public int compareTo(TimeAttestation o) { + BitcoinBlockHeaderAttestation ob = (BitcoinBlockHeaderAttestation) o; + + return this.height - ob.height; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof BitcoinBlockHeaderAttestation)) { + return false; + } + + if (!Arrays.equals(this._TAG(), ((BitcoinBlockHeaderAttestation) obj)._TAG())) { + return false; + } + + if (this.height != ((BitcoinBlockHeaderAttestation) obj).height) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return Arrays.hashCode(this._TAG()) ^ this.height; + } + + /** + * Verify attestation against a Bitcoin block header. + * @param digest the digest + * @param block the Bitcoin block header + * @return the block time on success; raises VerificationError on failure. + * @throws VerificationException verification exception + */ + public Long verifyAgainstBlockheader(byte[] digest, BlockHeader block) throws VerificationException { + if (digest.length != 32) { + throw new VerificationException("Expected digest with length 32 bytes; got " + digest.length + " bytes"); + } else if (!Arrays.equals(digest, Utils.hexToBytes(block.getMerkleroot()))) { + throw new VerificationException("Digest does not match merkleroot"); + } + + return block.getTime(); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/EthereumBlockHeaderAttestation.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/EthereumBlockHeaderAttestation.java new file mode 100644 index 000000000..bce68c9a8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/EthereumBlockHeaderAttestation.java @@ -0,0 +1,79 @@ +package com.vitorpamplona.quartz.ots.attestation; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Ethereum Block Header Attestation. + * + * @see TimeAttestation + */ +public class EthereumBlockHeaderAttestation extends TimeAttestation { + + public static byte[] _TAG = {(byte) 0x30, (byte) 0xfe, (byte) 0x80, (byte) 0x87, (byte) 0xb5, (byte) 0xc7, (byte) 0xea, (byte) 0xd7}; + public static String chain = "ethereum"; + + @Override + public byte[] _TAG() { + return EthereumBlockHeaderAttestation._TAG; + } + + private int height = 0; + + public int getHeight() { + return height; + } + + EthereumBlockHeaderAttestation(int height_) { + super(); + this.height = height_; + } + + public static EthereumBlockHeaderAttestation deserialize(StreamDeserializationContext ctxPayload) { + int height = ctxPayload.readVaruint(); + + return new EthereumBlockHeaderAttestation(height); + } + + @Override + public void serializePayload(StreamSerializationContext ctx) { + ctx.writeVaruint(this.height); + } + + public String toString() { + return "EthereumBlockHeaderAttestation(" + this.height + ")"; + } + + @Override + public int compareTo(TimeAttestation o) { + EthereumBlockHeaderAttestation ob = (EthereumBlockHeaderAttestation) o; + + return this.height - ob.height; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof EthereumBlockHeaderAttestation)) { + return false; + } + + if (!Arrays.equals(this._TAG(), ((EthereumBlockHeaderAttestation) obj)._TAG())) { + return false; + } + + if (this.height != ((EthereumBlockHeaderAttestation) obj).height) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return Arrays.hashCode(this._TAG()) ^ this.height; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/LitecoinBlockHeaderAttestation.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/LitecoinBlockHeaderAttestation.java new file mode 100644 index 000000000..548d88e24 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/LitecoinBlockHeaderAttestation.java @@ -0,0 +1,99 @@ +package com.vitorpamplona.quartz.ots.attestation; + +import com.vitorpamplona.quartz.ots.BlockHeader; +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; +import com.vitorpamplona.quartz.ots.exceptions.VerificationException; + +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Litecoin Block Header Attestation. + * + * @see TimeAttestation + */ +public class LitecoinBlockHeaderAttestation extends TimeAttestation { + + public static byte[] _TAG = {(byte) 0x06, (byte) 0x86, (byte) 0x9a, (byte) 0x0d, (byte) 0x73, (byte) 0xd7, (byte) 0x1b, (byte) 0x45}; + + public static String chain = "litecoin"; + + @Override + public byte[] _TAG() { + return LitecoinBlockHeaderAttestation._TAG; + } + + private int height = 0; + + public int getHeight() { + return height; + } + + public LitecoinBlockHeaderAttestation(int height_) { + super(); + this.height = height_; + } + + public static LitecoinBlockHeaderAttestation deserialize(StreamDeserializationContext ctxPayload) { + int height = ctxPayload.readVaruint(); + + return new LitecoinBlockHeaderAttestation(height); + } + + @Override + public void serializePayload(StreamSerializationContext ctx) { + ctx.writeVaruint(this.height); + } + + public String toString() { + return "LitecoinBlockHeaderAttestation(" + this.height + ")"; + } + + @Override + public int compareTo(TimeAttestation o) { + LitecoinBlockHeaderAttestation ob = (LitecoinBlockHeaderAttestation) o; + + return this.height - ob.height; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof LitecoinBlockHeaderAttestation)) { + return false; + } + + if (!Arrays.equals(this._TAG(), ((LitecoinBlockHeaderAttestation) obj)._TAG())) { + return false; + } + + if (this.height != ((LitecoinBlockHeaderAttestation) obj).height) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return Arrays.hashCode(this._TAG()) ^ this.height; + } + + /** + * Verify attestation against a Litecoin block header. + * @param digest the digest + * @param block the Litecoin block header + * @return the block time on success; raises VerificationError on failure. + * @throws VerificationException verification exception + */ + public Long verifyAgainstBlockheader(byte[] digest, BlockHeader block) throws VerificationException { + if (digest.length != 32) { + throw new VerificationException("Expected digest with length 32 bytes; got " + digest.length + " bytes"); + } else if (!Arrays.equals(digest, Utils.hexToBytes(block.getMerkleroot()))) { + throw new VerificationException("Digest does not match merkleroot"); + } + + return block.getTime(); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/PendingAttestation.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/PendingAttestation.java new file mode 100644 index 000000000..5e9a44390 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/PendingAttestation.java @@ -0,0 +1,134 @@ +package com.vitorpamplona.quartz.ots.attestation; + +import android.util.Log; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Pending attestations. + * Commitment has been recorded in a remote calendar for future attestation, + * and we have a URI to find a more complete timestamp in the future. + * Nothing other than the URI is recorded, nor is there provision made to add + * extra metadata (other than the URI) in future upgrades. The rational here + * is that remote calendars promise to keep commitments indefinitely, so from + * the moment they are created it should be possible to find the commitment in + * the calendar. Thus if you're not satisfied with the local verifiability of + * a timestamp, the correct thing to do is just ask the remote calendar if + * additional attestations are available and/or when they'll be available. + * While we could additional metadata like what types of attestations the + * remote calendar expects to be able to provide in the future, that metadata + * can easily change in the future too. Given that we don't expect timestamps + * to normally have more than a small number of remote calendar attestations, + * it'd be better to have verifiers get the most recent status of such + * information (possibly with appropriate negative response caching). + * + * @see TimeAttestation + */ +public class PendingAttestation extends TimeAttestation { + + public static byte[] _TAG = {(byte) 0x83, (byte) 0xdf, (byte) 0xe3, (byte) 0x0d, (byte) 0x2e, (byte) 0xf9, (byte) 0x0c, (byte) 0x8e}; + + @Override + public byte[] _TAG() { + return PendingAttestation._TAG; + } + + public static int _MAX_URI_LENGTH = 1000; + + public static String _ALLOWED_URI_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._/:"; + + private byte[] uri; + + public byte[] getUri() { + return uri; + } + + public PendingAttestation(byte[] uri_) { + super(); + this.uri = uri_; + } + + public static boolean checkUri(byte[] uri) { + if (uri.length > PendingAttestation._MAX_URI_LENGTH) { + System.err.print("URI exceeds maximum length"); + + return false; + } + + for (int i = 0; i < uri.length; i++) { + Character c = String.format("%c", uri[i]).charAt(0); + + if (PendingAttestation._ALLOWED_URI_CHARS.indexOf(c) < 0) { + Log.e("OpenTimestamp","URI contains invalid character "); + + return false; + } + } + + return true; + } + + public static PendingAttestation deserialize(StreamDeserializationContext ctxPayload) + throws DeserializationException { + + byte[] utf8Uri; + try { + utf8Uri = ctxPayload.readVarbytes(PendingAttestation._MAX_URI_LENGTH); + } catch (DeserializationException e) { + Log.e("OpenTimestamp","URI too long and thus invalid: "); + throw new DeserializationException("Invalid URI: "); + } + + if (PendingAttestation.checkUri(utf8Uri) == false) { + Log.e("OpenTimestamp","Invalid URI: "); + throw new DeserializationException("Invalid URI: "); + } + + return new PendingAttestation(utf8Uri); + } + + @Override + public void serializePayload(StreamSerializationContext ctx) { + ctx.writeVarbytes(this.uri); + } + + public String toString() { + return "PendingAttestation(\'" + new String(this.uri, StandardCharsets.UTF_8) + "\')"; + } + + @Override + public int compareTo(TimeAttestation o) { + PendingAttestation opa = (PendingAttestation) o; + + return Utils.compare(this.uri, opa.uri); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof PendingAttestation)) { + return false; + } + + if (!Arrays.equals(this._TAG(), ((PendingAttestation) obj)._TAG())) { + return false; + } + + if (!Arrays.equals(this.uri, ((PendingAttestation) obj).uri)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return Arrays.hashCode(this._TAG()) ^ Arrays.hashCode(this.uri); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/TimeAttestation.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/TimeAttestation.java new file mode 100644 index 000000000..39325530c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/TimeAttestation.java @@ -0,0 +1,83 @@ +package com.vitorpamplona.quartz.ots.attestation; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Class representing {@link com.vitorpamplona.quartz.ots.Timestamp} signature verification + */ +public abstract class TimeAttestation implements Comparable { + + public static int _TAG_SIZE = 8; + + public static int _MAX_PAYLOAD_SIZE = 8192; + + public byte[] _TAG; + + public byte[] _TAG() { + return new byte[]{}; + } + + /** + * Deserialize a general Time Attestation to the specific subclass Attestation. + * + * @param ctx The stream deserialization context. + * @return The specific subclass Attestation. + */ + public static TimeAttestation deserialize(StreamDeserializationContext ctx) throws DeserializationException { + // console.log('attestation deserialize'); + + byte[] tag = ctx.readBytes(_TAG_SIZE); + // console.log('tag: ', com.vitorpamplona.quartz.ots.Utils.bytesToHex(tag)); + + byte[] serializedAttestation = ctx.readVarbytes(_MAX_PAYLOAD_SIZE); + // console.log('serializedAttestation: ', com.vitorpamplona.quartz.ots.Utils.bytesToHex(serializedAttestation)); + + StreamDeserializationContext ctxPayload = new StreamDeserializationContext(serializedAttestation); + + /* eslint no-use-before-define: ["error", { "classes": false }] */ + if (Arrays.equals(tag, PendingAttestation._TAG) == true) { + return PendingAttestation.deserialize(ctxPayload); + } else if (Arrays.equals(tag, BitcoinBlockHeaderAttestation._TAG) == true) { + return BitcoinBlockHeaderAttestation.deserialize(ctxPayload); + } else if (Arrays.equals(tag, LitecoinBlockHeaderAttestation._TAG) == true) { + return LitecoinBlockHeaderAttestation.deserialize(ctxPayload); + } else if (Arrays.equals(tag, EthereumBlockHeaderAttestation._TAG) == true) { + return EthereumBlockHeaderAttestation.deserialize(ctxPayload); + } + + return new UnknownAttestation(tag, serializedAttestation); + } + + /** + * Serialize a a general Time Attestation to the specific subclass Attestation. + * + * @param ctx The output stream serialization context. + */ + public void serialize(StreamSerializationContext ctx) { + ctx.writeBytes(this._TAG()); + StreamSerializationContext ctxPayload = new StreamSerializationContext(); + serializePayload(ctxPayload); + ctx.writeVarbytes(ctxPayload.getOutput()); + } + + public void serializePayload(StreamSerializationContext ctxPayload) { + // TODO: Is this intentional? + } + + @Override + public int compareTo(TimeAttestation o) { + int deltaTag = Utils.compare(this._TAG(), o._TAG()); + + if (deltaTag == 0) { + return this.compareTo(o); + } else { + return deltaTag; + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/UnknownAttestation.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/UnknownAttestation.java new file mode 100644 index 000000000..6baf6879a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/attestation/UnknownAttestation.java @@ -0,0 +1,76 @@ +package com.vitorpamplona.quartz.ots.attestation; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Placeholder for attestations that don't support + * + * @see TimeAttestation + */ +public class UnknownAttestation extends TimeAttestation { + + byte[] payload; + + public static byte[] _TAG = new byte[]{}; + + @Override + public byte[] _TAG() { + return _TAG; + } + + UnknownAttestation(byte[] tag, byte[] payload) { + super(); + this._TAG = tag; + this.payload = payload; + } + + @Override + public void serializePayload(StreamSerializationContext ctx) { + ctx.writeBytes(this.payload); + } + + public static UnknownAttestation deserialize(StreamDeserializationContext ctxPayload, byte[] tag) throws DeserializationException { + byte[] payload = ctxPayload.readVarbytes(_MAX_PAYLOAD_SIZE); + + return new UnknownAttestation(tag, payload); + } + + public String toString() { + return "UnknownAttestation " + Utils.bytesToHex(this._TAG()) + ' ' + Utils.bytesToHex(this.payload); + } + + @Override + public int compareTo(TimeAttestation o) { + UnknownAttestation ota = (UnknownAttestation) o; + + return Utils.compare(this.payload, ota.payload); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof UnknownAttestation)) { + return false; + } + + if (!Arrays.equals(this._TAG(), ((UnknownAttestation) obj)._TAG())) { + return false; + } + + if (!Arrays.equals(this.payload, ((UnknownAttestation) obj).payload)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return Arrays.hashCode(this._TAG()) ^ Arrays.hashCode(this.payload); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Digest.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Digest.java new file mode 100644 index 000000000..c0c3bd7f2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Digest.java @@ -0,0 +1,52 @@ +package com.vitorpamplona.quartz.ots.crypto; + +/** + * Message digest interface + */ +public interface Digest { + /** + * Return the algorithm name + * + * @return the algorithm name + */ + public String getAlgorithmName(); + + /** + * Return the size, in bytes, of the digest produced by this message digest. + * + * @return the size, in bytes, of the digest produced by this message digest. + */ + public int getDigestSize(); + + /** + * Update the message digest with a single byte. + * + * @param in the input byte to be entered. + */ + public void update(byte in); + + /** + * Update the message digest with a block of bytes. + * + * @param in the byte array containing the data. + * @param inOff the offset into the byte array where the data starts. + * @param len the length of the data. + */ + public void update(byte[] in, int inOff, int len); + + /** + * Close the digest, producing the final digest value. The doFinal + * call also resets the digest. + * + * @param out the array the digest is to be copied into. + * @param outOff the offset into the out array the digest is to start at. + * @return something + * @see #reset() + */ + public int doFinal(byte[] out, int outOff); + + /** + * Reset the digest back to it's initial state. + */ + public void reset(); +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/ExtendedDigest.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/ExtendedDigest.java new file mode 100644 index 000000000..df08f1191 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/ExtendedDigest.java @@ -0,0 +1,11 @@ +package com.vitorpamplona.quartz.ots.crypto; + +public interface ExtendedDigest extends Digest { + /** + * Return the size in bytes of the internal buffer the digest applies it's compression + * function to. + * + * @return byte length of the digests internal buffer. + */ + public int getByteLength(); +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/GeneralDigest.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/GeneralDigest.java new file mode 100644 index 000000000..021eb4675 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/GeneralDigest.java @@ -0,0 +1,138 @@ +package com.vitorpamplona.quartz.ots.crypto; + +/** + * Base implementation of MD4 family style digest as outlined in + * "Handbook of Applied Cryptography", pages 344 - 347. + */ +public abstract class GeneralDigest implements ExtendedDigest, Memoable { + + private static final int BYTE_LENGTH = 64; + + private final byte[] xBuf = new byte[4]; + private int xBufOff; + + private long byteCount; + + /** + * Standard constructor + */ + protected GeneralDigest() { + xBufOff = 0; + } + + /** + * Copy constructor. We are using copy constructors in place + * of the Object.clone() interface as this interface is not + * supported by J2ME. + * + * @param t the GeneralDigest + */ + protected GeneralDigest(GeneralDigest t) { + copyIn(t); + } + + protected GeneralDigest(byte[] encodedState) { + System.arraycopy(encodedState, 0, xBuf, 0, xBuf.length); + xBufOff = Pack.bigEndianToInt(encodedState, 4); + byteCount = Pack.bigEndianToLong(encodedState, 8); + } + + protected void copyIn(GeneralDigest t) { + System.arraycopy(t.xBuf, 0, xBuf, 0, t.xBuf.length); + + xBufOff = t.xBufOff; + byteCount = t.byteCount; + } + + public void update(byte in) { + xBuf[xBufOff++] = in; + + if (xBufOff == xBuf.length) { + processWord(xBuf, 0); + xBufOff = 0; + } + + byteCount++; + } + + public void update(byte[] in, int inOff, int len) { + len = Math.max(0, len); + + // + // fill the current word + // + int i = 0; + + if (xBufOff != 0) { + while (i < len) { + xBuf[xBufOff++] = in[inOff + i++]; + + if (xBufOff == 4) { + processWord(xBuf, 0); + xBufOff = 0; + break; + } + } + } + + // + // process whole words. + // + int limit = ((len - i) & ~3) + i; + + for (; i < limit; i += 4) { + processWord(in, inOff + i); + } + + // + // load in the remainder. + // + while (i < len) { + xBuf[xBufOff++] = in[inOff + i++]; + } + + byteCount += len; + } + + public void finish() { + long bitLength = (byteCount << 3); + + // + // add the pad bytes. + // + update((byte) 128); + + while (xBufOff != 0) { + update((byte) 0); + } + + processLength(bitLength); + processBlock(); + } + + public void reset() { + byteCount = 0; + + xBufOff = 0; + + for (int i = 0; i < xBuf.length; i++) { + xBuf[i] = 0; + } + } + + protected void populateState(byte[] state) { + System.arraycopy(xBuf, 0, state, 0, xBufOff); + Pack.intToBigEndian(xBufOff, state, 4); + Pack.longToBigEndian(byteCount, state, 8); + } + + public int getByteLength() { + return BYTE_LENGTH; + } + + protected abstract void processWord(byte[] in, int inOff); + + protected abstract void processLength(long bitLength); + + protected abstract void processBlock(); +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/KeccakDigest.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/KeccakDigest.java new file mode 100644 index 000000000..6053f72dc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/KeccakDigest.java @@ -0,0 +1,473 @@ +package com.vitorpamplona.quartz.ots.crypto; + +import com.vitorpamplona.quartz.ots.Utils; + +/** + * Implementation of Keccak based on following KeccakNISTInterface.c from http://keccak.noekeon.org/ + *

+ * Following the naming conventions used in the C source code to enable easy review of the implementation. + */ +public class KeccakDigest implements ExtendedDigest { + + private static long[] KeccakRoundConstants = keccakInitializeRoundConstants(); + + private static int[] KeccakRhoOffsets = keccakInitializeRhoOffsets(); + + private static long[] keccakInitializeRoundConstants() { + long[] keccakRoundConstants = new long[24]; + byte[] LFSRstate = new byte[1]; + + LFSRstate[0] = 0x01; + int i, j, bitPosition; + + for (i = 0; i < 24; i++) { + keccakRoundConstants[i] = 0; + + for (j = 0; j < 7; j++) { + bitPosition = (1 << j) - 1; + + if (LFSR86540(LFSRstate)) { + keccakRoundConstants[i] ^= 1L << bitPosition; + } + } + } + + return keccakRoundConstants; + } + + private static boolean LFSR86540(byte[] LFSR) { + boolean result = (((LFSR[0]) & 0x01) != 0); + + if (((LFSR[0]) & 0x80) != 0) { + LFSR[0] = (byte) (((LFSR[0]) << 1) ^ 0x71); + } else { + LFSR[0] <<= 1; + } + + return result; + } + + private static int[] keccakInitializeRhoOffsets() { + int[] keccakRhoOffsets = new int[25]; + int x, y, t, newX, newY; + + keccakRhoOffsets[(((0) % 5) + 5 * ((0) % 5))] = 0; + x = 1; + y = 0; + + for (t = 0; t < 24; t++) { + keccakRhoOffsets[(((x) % 5) + 5 * ((y) % 5))] = ((t + 1) * (t + 2) / 2) % 64; + newX = (0 * x + 1 * y) % 5; + newY = (2 * x + 3 * y) % 5; + x = newX; + y = newY; + } + + return keccakRhoOffsets; + } + + protected byte[] state = new byte[(1600 / 8)]; + protected byte[] dataQueue = new byte[(1536 / 8)]; + protected int rate; + protected int bitsInQueue; + protected int fixedOutputLength; + protected boolean squeezing; + protected int bitsAvailableForSqueezing; + protected byte[] chunk; + protected byte[] oneByte; + + private void clearDataQueueSection(int off, int len) { + for (int i = off; i != off + len; i++) { + dataQueue[i] = 0; + } + } + + public KeccakDigest() { + this(288); + } + + public KeccakDigest(int bitLength) { + init(bitLength); + } + + public KeccakDigest(KeccakDigest source) { + System.arraycopy(source.state, 0, this.state, 0, source.state.length); + System.arraycopy(source.dataQueue, 0, this.dataQueue, 0, source.dataQueue.length); + this.rate = source.rate; + this.bitsInQueue = source.bitsInQueue; + this.fixedOutputLength = source.fixedOutputLength; + this.squeezing = source.squeezing; + this.bitsAvailableForSqueezing = source.bitsAvailableForSqueezing; + this.chunk = Utils.arraysCopy(source.chunk); + this.oneByte = Utils.arraysCopy(source.oneByte); + } + + public String getAlgorithmName() { + return "Keccak-" + fixedOutputLength; + } + + public int getDigestSize() { + return fixedOutputLength / 8; + } + + public void update(byte in) { + oneByte[0] = in; + + absorb(oneByte, 0, 8L); + } + + public void update(byte[] in, int inOff, int len) { + absorb(in, inOff, len * 8L); + } + + public int doFinal(byte[] out, int outOff) { + squeeze(out, outOff, fixedOutputLength); + reset(); + + return getDigestSize(); + } + + /* + * TODO Possible API change to support partial-byte suffixes. + */ + protected int doFinal(byte[] out, int outOff, byte partialByte, int partialBits) { + if (partialBits > 0) { + oneByte[0] = partialByte; + absorb(oneByte, 0, partialBits); + } + + squeeze(out, outOff, fixedOutputLength); + reset(); + + return getDigestSize(); + } + + public void reset() { + init(fixedOutputLength); + } + + /** + * Return the size of block that the compression function is applied to in bytes. + * + * @return internal byte length of a block. + */ + public int getByteLength() { + return rate / 8; + } + + private void init(int bitLength) { + switch (bitLength) { + case 288: + initSponge(1024, 576); + break; + case 128: + initSponge(1344, 256); + break; + case 224: + initSponge(1152, 448); + break; + case 256: + initSponge(1088, 512); + break; + case 384: + initSponge(832, 768); + break; + case 512: + initSponge(576, 1024); + break; + default: + throw new IllegalArgumentException("bitLength must be one of 128, 224, 256, 288, 384, or 512."); + } + } + + private void initSponge(int rate, int capacity) { + if (rate + capacity != 1600) { + throw new IllegalStateException("rate + capacity != 1600"); + } + + if ((rate <= 0) || (rate >= 1600) || ((rate % 64) != 0)) { + throw new IllegalStateException("invalid rate value"); + } + + this.rate = rate; + // this is never read, need to check to see why we want to save it + // this.capacity = capacity; + Utils.arrayFill(this.state, (byte) 0); + Utils.arrayFill(this.dataQueue, (byte) 0); + this.bitsInQueue = 0; + this.squeezing = false; + this.bitsAvailableForSqueezing = 0; + this.fixedOutputLength = capacity / 2; + this.chunk = new byte[rate / 8]; + this.oneByte = new byte[1]; + } + + private void absorbQueue() { + KeccakAbsorb(state, dataQueue, rate / 8); + bitsInQueue = 0; + } + + protected void absorb(byte[] data, int off, long databitlen) { + long i, j, wholeBlocks; + + if ((bitsInQueue % 8) != 0) { + throw new IllegalStateException("attempt to absorb with odd length queue"); + } + + if (squeezing) { + throw new IllegalStateException("attempt to absorb while squeezing"); + } + + i = 0; + + while (i < databitlen) { + if ((bitsInQueue == 0) && (databitlen >= rate) && (i <= (databitlen - rate))) { + wholeBlocks = (databitlen - i) / rate; + + for (j = 0; j < wholeBlocks; j++) { + System.arraycopy(data, (int) (off + (i / 8) + (j * chunk.length)), chunk, 0, chunk.length); + +// displayIntermediateValues.displayBytes(1, "Block to be absorbed", curData, rate / 8); + + KeccakAbsorb(state, chunk, chunk.length); + } + + i += wholeBlocks * rate; + } else { + int partialBlock = (int) (databitlen - i); + + if (partialBlock + bitsInQueue > rate) { + partialBlock = rate - bitsInQueue; + } + + int partialByte = partialBlock % 8; + partialBlock -= partialByte; + System.arraycopy(data, off + (int) (i / 8), dataQueue, bitsInQueue / 8, partialBlock / 8); + + bitsInQueue += partialBlock; + i += partialBlock; + + if (bitsInQueue == rate) { + absorbQueue(); + } + + if (partialByte > 0) { + int mask = (1 << partialByte) - 1; + dataQueue[bitsInQueue / 8] = (byte) (data[off + ((int) (i / 8))] & mask); + bitsInQueue += partialByte; + i += partialByte; + } + } + } + } + + private void padAndSwitchToSqueezingPhase() { + if (bitsInQueue + 1 == rate) { + dataQueue[bitsInQueue / 8] |= 1 << (bitsInQueue % 8); + absorbQueue(); + clearDataQueueSection(0, rate / 8); + } else { + clearDataQueueSection((bitsInQueue + 7) / 8, rate / 8 - (bitsInQueue + 7) / 8); + dataQueue[bitsInQueue / 8] |= 1 << (bitsInQueue % 8); + } + + dataQueue[(rate - 1) / 8] |= 1 << ((rate - 1) % 8); + absorbQueue(); + +// displayIntermediateValues.displayText(1, "--- Switching to squeezing phase ---"); + + if (rate == 1024) { + KeccakExtract1024bits(state, dataQueue); + bitsAvailableForSqueezing = 1024; + } else { + KeccakExtract(state, dataQueue, rate / 64); + bitsAvailableForSqueezing = rate; + } + +// displayIntermediateValues.displayBytes(1, "Block available for squeezing", dataQueue, bitsAvailableForSqueezing / 8); + + squeezing = true; + } + + protected void squeeze(byte[] output, int offset, long outputLength) { + long i; + int partialBlock; + + if (!squeezing) { + padAndSwitchToSqueezingPhase(); + } + + if ((outputLength % 8) != 0) { + throw new IllegalStateException("outputLength not a multiple of 8"); + } + + i = 0; + + while (i < outputLength) { + if (bitsAvailableForSqueezing == 0) { + keccakPermutation(state); + + if (rate == 1024) { + KeccakExtract1024bits(state, dataQueue); + bitsAvailableForSqueezing = 1024; + } else { + KeccakExtract(state, dataQueue, rate / 64); + bitsAvailableForSqueezing = rate; + } + +// displayIntermediateValues.displayBytes(1, "Block available for squeezing", dataQueue, bitsAvailableForSqueezing / 8); + + } + + partialBlock = bitsAvailableForSqueezing; + + if ((long) partialBlock > outputLength - i) { + partialBlock = (int) (outputLength - i); + } + + System.arraycopy(dataQueue, (rate - bitsAvailableForSqueezing) / 8, output, offset + (int) (i / 8), partialBlock / 8); + bitsAvailableForSqueezing -= partialBlock; + i += partialBlock; + } + } + + private void fromBytesToWords(long[] stateAsWords, byte[] state) { + for (int i = 0; i < (1600 / 64); i++) { + stateAsWords[i] = 0; + int index = i * (64 / 8); + + for (int j = 0; j < (64 / 8); j++) { + stateAsWords[i] |= ((long) state[index + j] & 0xff) << ((8 * j)); + } + } + } + + private void fromWordsToBytes(byte[] state, long[] stateAsWords) { + for (int i = 0; i < (1600 / 64); i++) { + int index = i * (64 / 8); + + for (int j = 0; j < (64 / 8); j++) { + state[index + j] = (byte) ((stateAsWords[i] >>> ((8 * j))) & 0xFF); + } + } + } + + private void keccakPermutation(byte[] state) { + long[] longState = new long[state.length / 8]; + + fromBytesToWords(longState, state); + +// displayIntermediateValues.displayStateAsBytes(1, "Input of permutation", longState); + + keccakPermutationOnWords(longState); + +// displayIntermediateValues.displayStateAsBytes(1, "State after permutation", longState); + + fromWordsToBytes(state, longState); + } + + private void keccakPermutationAfterXor(byte[] state, byte[] data, int dataLengthInBytes) { + int i; + + for (i = 0; i < dataLengthInBytes; i++) { + state[i] ^= data[i]; + } + + keccakPermutation(state); + } + + private void keccakPermutationOnWords(long[] state) { + int i; + +// displayIntermediateValues.displayStateAs64bitWords(3, "Same, with lanes as 64-bit words", state); + + for (i = 0; i < 24; i++) { +// displayIntermediateValues.displayRoundNumber(3, i); + + theta(state); +// displayIntermediateValues.displayStateAs64bitWords(3, "After theta", state); + + rho(state); +// displayIntermediateValues.displayStateAs64bitWords(3, "After rho", state); + + pi(state); +// displayIntermediateValues.displayStateAs64bitWords(3, "After pi", state); + + chi(state); +// displayIntermediateValues.displayStateAs64bitWords(3, "After chi", state); + + iota(state, i); +// displayIntermediateValues.displayStateAs64bitWords(3, "After iota", state); + } + } + + long[] C = new long[5]; + + private void theta(long[] A) { + for (int x = 0; x < 5; x++) { + C[x] = 0; + + for (int y = 0; y < 5; y++) { + C[x] ^= A[x + 5 * y]; + } + } + for (int x = 0; x < 5; x++) { + long dX = ((((C[(x + 1) % 5]) << 1) ^ ((C[(x + 1) % 5]) >>> (64 - 1)))) ^ C[(x + 4) % 5]; + + for (int y = 0; y < 5; y++) { + A[x + 5 * y] ^= dX; + } + } + } + + private void rho(long[] A) { + for (int x = 0; x < 5; x++) { + for (int y = 0; y < 5; y++) { + int index = x + 5 * y; + A[index] = ((KeccakRhoOffsets[index] != 0) ? (((A[index]) << KeccakRhoOffsets[index]) ^ ((A[index]) >>> (64 - KeccakRhoOffsets[index]))) : A[index]); + } + } + } + + long[] tempA = new long[25]; + + private void pi(long[] A) { + System.arraycopy(A, 0, tempA, 0, tempA.length); + + for (int x = 0; x < 5; x++) { + for (int y = 0; y < 5; y++) { + A[y + 5 * ((2 * x + 3 * y) % 5)] = tempA[x + 5 * y]; + } + } + } + + long[] chiC = new long[5]; + + private void chi(long[] A) { + for (int y = 0; y < 5; y++) { + for (int x = 0; x < 5; x++) { + chiC[x] = A[x + 5 * y] ^ ((~A[(((x + 1) % 5) + 5 * y)]) & A[(((x + 2) % 5) + 5 * y)]); + } + + for (int x = 0; x < 5; x++) { + A[x + 5 * y] = chiC[x]; + } + } + } + + private void iota(long[] A, int indexRound) { + A[(((0) % 5) + 5 * ((0) % 5))] ^= KeccakRoundConstants[indexRound]; + } + + private void KeccakAbsorb(byte[] byteState, byte[] data, int dataInBytes) { + keccakPermutationAfterXor(byteState, data, dataInBytes); + } + + private void KeccakExtract1024bits(byte[] byteState, byte[] data) { + System.arraycopy(byteState, 0, data, 0, 128); + } + + private void KeccakExtract(byte[] byteState, byte[] data, int laneCount) { + System.arraycopy(byteState, 0, data, 0, laneCount * 8); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Memoable.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Memoable.java new file mode 100644 index 000000000..062b836a3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Memoable.java @@ -0,0 +1,28 @@ +package com.vitorpamplona.quartz.ots.crypto; + +/** + * Interface for Memoable objects. Memoable objects allow the taking of a snapshot of their internal state + * via the {@link #copy copy()} method and then resetting the object back to that state later using the + * {@link #reset reset()} method. + */ +public interface Memoable { + /** + * Produce a copy of this object with its configuration and in its current state. + *

+ * The returned object may be used simply to store the state, or may be used as a similar object + * starting from the copied state. + * + * @return Memoable object + */ + Memoable copy(); + + /** + * Restore a copied object state into this object. + *

+ * Implementations of this method should try to avoid or minimise memory allocation to perform the reset. + * + * @param other an object originally {@link #copy() copied} from an object of the same type as this instance. + * @throws ClassCastException if the provided object is not of the correct type. + */ + void reset(Memoable other); +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Pack.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Pack.java new file mode 100644 index 000000000..17c839703 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/Pack.java @@ -0,0 +1,242 @@ +package com.vitorpamplona.quartz.ots.crypto; + +/** + * Utility methods for converting byte arrays into ints and longs, and back again. + */ +public abstract class Pack +{ + public static short bigEndianToShort(byte[] bs, int off) + { + int n = (bs[ off] & 0xff) << 8; + n |= (bs[++off] & 0xff); + return (short)n; + } + + public static int bigEndianToInt(byte[] bs, int off) + { + int n = bs[ off] << 24; + n |= (bs[++off] & 0xff) << 16; + n |= (bs[++off] & 0xff) << 8; + n |= (bs[++off] & 0xff); + return n; + } + + public static void bigEndianToInt(byte[] bs, int off, int[] ns) + { + for (int i = 0; i < ns.length; ++i) + { + ns[i] = bigEndianToInt(bs, off); + off += 4; + } + } + + public static byte[] intToBigEndian(int n) + { + byte[] bs = new byte[4]; + intToBigEndian(n, bs, 0); + return bs; + } + + public static void intToBigEndian(int n, byte[] bs, int off) + { + bs[ off] = (byte)(n >>> 24); + bs[++off] = (byte)(n >>> 16); + bs[++off] = (byte)(n >>> 8); + bs[++off] = (byte)(n ); + } + + public static byte[] intToBigEndian(int[] ns) + { + byte[] bs = new byte[4 * ns.length]; + intToBigEndian(ns, bs, 0); + return bs; + } + + public static void intToBigEndian(int[] ns, byte[] bs, int off) + { + for (int i = 0; i < ns.length; ++i) + { + intToBigEndian(ns[i], bs, off); + off += 4; + } + } + + public static long bigEndianToLong(byte[] bs, int off) + { + int hi = bigEndianToInt(bs, off); + int lo = bigEndianToInt(bs, off + 4); + return ((long)(hi & 0xffffffffL) << 32) | (long)(lo & 0xffffffffL); + } + + public static void bigEndianToLong(byte[] bs, int off, long[] ns) + { + for (int i = 0; i < ns.length; ++i) + { + ns[i] = bigEndianToLong(bs, off); + off += 8; + } + } + + public static byte[] longToBigEndian(long n) + { + byte[] bs = new byte[8]; + longToBigEndian(n, bs, 0); + return bs; + } + + public static void longToBigEndian(long n, byte[] bs, int off) + { + intToBigEndian((int)(n >>> 32), bs, off); + intToBigEndian((int)(n & 0xffffffffL), bs, off + 4); + } + + public static byte[] longToBigEndian(long[] ns) + { + byte[] bs = new byte[8 * ns.length]; + longToBigEndian(ns, bs, 0); + return bs; + } + + public static void longToBigEndian(long[] ns, byte[] bs, int off) + { + for (int i = 0; i < ns.length; ++i) + { + longToBigEndian(ns[i], bs, off); + off += 8; + } + } + + public static short littleEndianToShort(byte[] bs, int off) + { + int n = bs[ off] & 0xff; + n |= (bs[++off] & 0xff) << 8; + return (short)n; + } + + public static int littleEndianToInt(byte[] bs, int off) + { + int n = bs[ off] & 0xff; + n |= (bs[++off] & 0xff) << 8; + n |= (bs[++off] & 0xff) << 16; + n |= bs[++off] << 24; + return n; + } + + public static void littleEndianToInt(byte[] bs, int off, int[] ns) + { + for (int i = 0; i < ns.length; ++i) + { + ns[i] = littleEndianToInt(bs, off); + off += 4; + } + } + + public static void littleEndianToInt(byte[] bs, int bOff, int[] ns, int nOff, int count) + { + for (int i = 0; i < count; ++i) + { + ns[nOff + i] = littleEndianToInt(bs, bOff); + bOff += 4; + } + } + + public static int[] littleEndianToInt(byte[] bs, int off, int count) + { + int[] ns = new int[count]; + for (int i = 0; i < ns.length; ++i) + { + ns[i] = littleEndianToInt(bs, off); + off += 4; + } + return ns; + } + + public static byte[] shortToLittleEndian(short n) + { + byte[] bs = new byte[2]; + shortToLittleEndian(n, bs, 0); + return bs; + } + + public static void shortToLittleEndian(short n, byte[] bs, int off) + { + bs[ off] = (byte)(n ); + bs[++off] = (byte)(n >>> 8); + } + + public static byte[] intToLittleEndian(int n) + { + byte[] bs = new byte[4]; + intToLittleEndian(n, bs, 0); + return bs; + } + + public static void intToLittleEndian(int n, byte[] bs, int off) + { + bs[ off] = (byte)(n ); + bs[++off] = (byte)(n >>> 8); + bs[++off] = (byte)(n >>> 16); + bs[++off] = (byte)(n >>> 24); + } + + public static byte[] intToLittleEndian(int[] ns) + { + byte[] bs = new byte[4 * ns.length]; + intToLittleEndian(ns, bs, 0); + return bs; + } + + public static void intToLittleEndian(int[] ns, byte[] bs, int off) + { + for (int i = 0; i < ns.length; ++i) + { + intToLittleEndian(ns[i], bs, off); + off += 4; + } + } + + public static long littleEndianToLong(byte[] bs, int off) + { + int lo = littleEndianToInt(bs, off); + int hi = littleEndianToInt(bs, off + 4); + return ((long)(hi & 0xffffffffL) << 32) | (long)(lo & 0xffffffffL); + } + + public static void littleEndianToLong(byte[] bs, int off, long[] ns) + { + for (int i = 0; i < ns.length; ++i) + { + ns[i] = littleEndianToLong(bs, off); + off += 8; + } + } + + public static byte[] longToLittleEndian(long n) + { + byte[] bs = new byte[8]; + longToLittleEndian(n, bs, 0); + return bs; + } + + public static void longToLittleEndian(long n, byte[] bs, int off) + { + intToLittleEndian((int)(n & 0xffffffffL), bs, off); + intToLittleEndian((int)(n >>> 32), bs, off + 4); + } + + public static byte[] longToLittleEndian(long[] ns) + { + byte[] bs = new byte[8 * ns.length]; + longToLittleEndian(ns, bs, 0); + return bs; + } + + public static void longToLittleEndian(long[] ns, byte[] bs, int off) + { + for (int i = 0; i < ns.length; ++i) + { + longToLittleEndian(ns[i], bs, off); + off += 8; + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/RIPEMD160Digest.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/RIPEMD160Digest.java new file mode 100644 index 000000000..51dfc8a69 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/crypto/RIPEMD160Digest.java @@ -0,0 +1,442 @@ +package com.vitorpamplona.quartz.ots.crypto; + +/** + * Implementation of RIPEMD + * + * @see ripemd160 + */ +public class RIPEMD160Digest + extends GeneralDigest +{ + private static final int DIGEST_LENGTH = 20; + + private int H0, H1, H2, H3, H4; // IV's + + private int[] X = new int[16]; + private int xOff; + + /** + * Standard constructor + */ + public RIPEMD160Digest() + { + reset(); + } + + /** + * Copy constructor. This will copy the state of the provided + * message digest. + * @param t RIPEMD160Digest + */ + public RIPEMD160Digest(RIPEMD160Digest t) + { + super(t); + + copyIn(t); + } + + private void copyIn(RIPEMD160Digest t) + { + super.copyIn(t); + + H0 = t.H0; + H1 = t.H1; + H2 = t.H2; + H3 = t.H3; + H4 = t.H4; + + System.arraycopy(t.X, 0, X, 0, t.X.length); + xOff = t.xOff; + } + + public String getAlgorithmName() + { + return "RIPEMD160"; + } + + public int getDigestSize() + { + return DIGEST_LENGTH; + } + + protected void processWord( + byte[] in, + int inOff) + { + X[xOff++] = (in[inOff] & 0xff) | ((in[inOff + 1] & 0xff) << 8) + | ((in[inOff + 2] & 0xff) << 16) | ((in[inOff + 3] & 0xff) << 24); + + if (xOff == 16) + { + processBlock(); + } + } + + protected void processLength( + long bitLength) + { + if (xOff > 14) + { + processBlock(); + } + + X[14] = (int)(bitLength & 0xffffffff); + X[15] = (int)(bitLength >>> 32); + } + + private void unpackWord( + int word, + byte[] out, + int outOff) + { + out[outOff] = (byte)word; + out[outOff + 1] = (byte)(word >>> 8); + out[outOff + 2] = (byte)(word >>> 16); + out[outOff + 3] = (byte)(word >>> 24); + } + + public int doFinal( + byte[] out, + int outOff) + { + finish(); + + unpackWord(H0, out, outOff); + unpackWord(H1, out, outOff + 4); + unpackWord(H2, out, outOff + 8); + unpackWord(H3, out, outOff + 12); + unpackWord(H4, out, outOff + 16); + + reset(); + + return DIGEST_LENGTH; + } + + /** + * reset the chaining variables to the IV values. + */ + public void reset() + { + super.reset(); + + H0 = 0x67452301; + H1 = 0xefcdab89; + H2 = 0x98badcfe; + H3 = 0x10325476; + H4 = 0xc3d2e1f0; + + xOff = 0; + + for (int i = 0; i != X.length; i++) + { + X[i] = 0; + } + } + + /* + * rotate int x left n bits. + */ + private int RL( + int x, + int n) + { + return (x << n) | (x >>> (32 - n)); + } + + /* + * f1,f2,f3,f4,f5 are the basic RIPEMD160 functions. + */ + + /* + * rounds 0-15 + */ + private int f1( + int x, + int y, + int z) + { + return x ^ y ^ z; + } + + /* + * rounds 16-31 + */ + private int f2( + int x, + int y, + int z) + { + return (x & y) | (~x & z); + } + + /* + * rounds 32-47 + */ + private int f3( + int x, + int y, + int z) + { + return (x | ~y) ^ z; + } + + /* + * rounds 48-63 + */ + private int f4( + int x, + int y, + int z) + { + return (x & z) | (y & ~z); + } + + /* + * rounds 64-79 + */ + private int f5( + int x, + int y, + int z) + { + return x ^ (y | ~z); + } + + protected void processBlock() + { + int a, aa; + int b, bb; + int c, cc; + int d, dd; + int e, ee; + + a = aa = H0; + b = bb = H1; + c = cc = H2; + d = dd = H3; + e = ee = H4; + + // + // Rounds 1 - 16 + // + // left + a = RL(a + f1(b,c,d) + X[ 0], 11) + e; c = RL(c, 10); + e = RL(e + f1(a,b,c) + X[ 1], 14) + d; b = RL(b, 10); + d = RL(d + f1(e,a,b) + X[ 2], 15) + c; a = RL(a, 10); + c = RL(c + f1(d,e,a) + X[ 3], 12) + b; e = RL(e, 10); + b = RL(b + f1(c,d,e) + X[ 4], 5) + a; d = RL(d, 10); + a = RL(a + f1(b,c,d) + X[ 5], 8) + e; c = RL(c, 10); + e = RL(e + f1(a,b,c) + X[ 6], 7) + d; b = RL(b, 10); + d = RL(d + f1(e,a,b) + X[ 7], 9) + c; a = RL(a, 10); + c = RL(c + f1(d,e,a) + X[ 8], 11) + b; e = RL(e, 10); + b = RL(b + f1(c,d,e) + X[ 9], 13) + a; d = RL(d, 10); + a = RL(a + f1(b,c,d) + X[10], 14) + e; c = RL(c, 10); + e = RL(e + f1(a,b,c) + X[11], 15) + d; b = RL(b, 10); + d = RL(d + f1(e,a,b) + X[12], 6) + c; a = RL(a, 10); + c = RL(c + f1(d,e,a) + X[13], 7) + b; e = RL(e, 10); + b = RL(b + f1(c,d,e) + X[14], 9) + a; d = RL(d, 10); + a = RL(a + f1(b,c,d) + X[15], 8) + e; c = RL(c, 10); + + // right + aa = RL(aa + f5(bb,cc,dd) + X[ 5] + 0x50a28be6, 8) + ee; cc = RL(cc, 10); + ee = RL(ee + f5(aa,bb,cc) + X[14] + 0x50a28be6, 9) + dd; bb = RL(bb, 10); + dd = RL(dd + f5(ee,aa,bb) + X[ 7] + 0x50a28be6, 9) + cc; aa = RL(aa, 10); + cc = RL(cc + f5(dd,ee,aa) + X[ 0] + 0x50a28be6, 11) + bb; ee = RL(ee, 10); + bb = RL(bb + f5(cc,dd,ee) + X[ 9] + 0x50a28be6, 13) + aa; dd = RL(dd, 10); + aa = RL(aa + f5(bb,cc,dd) + X[ 2] + 0x50a28be6, 15) + ee; cc = RL(cc, 10); + ee = RL(ee + f5(aa,bb,cc) + X[11] + 0x50a28be6, 15) + dd; bb = RL(bb, 10); + dd = RL(dd + f5(ee,aa,bb) + X[ 4] + 0x50a28be6, 5) + cc; aa = RL(aa, 10); + cc = RL(cc + f5(dd,ee,aa) + X[13] + 0x50a28be6, 7) + bb; ee = RL(ee, 10); + bb = RL(bb + f5(cc,dd,ee) + X[ 6] + 0x50a28be6, 7) + aa; dd = RL(dd, 10); + aa = RL(aa + f5(bb,cc,dd) + X[15] + 0x50a28be6, 8) + ee; cc = RL(cc, 10); + ee = RL(ee + f5(aa,bb,cc) + X[ 8] + 0x50a28be6, 11) + dd; bb = RL(bb, 10); + dd = RL(dd + f5(ee,aa,bb) + X[ 1] + 0x50a28be6, 14) + cc; aa = RL(aa, 10); + cc = RL(cc + f5(dd,ee,aa) + X[10] + 0x50a28be6, 14) + bb; ee = RL(ee, 10); + bb = RL(bb + f5(cc,dd,ee) + X[ 3] + 0x50a28be6, 12) + aa; dd = RL(dd, 10); + aa = RL(aa + f5(bb,cc,dd) + X[12] + 0x50a28be6, 6) + ee; cc = RL(cc, 10); + + // + // Rounds 16-31 + // + // left + e = RL(e + f2(a,b,c) + X[ 7] + 0x5a827999, 7) + d; b = RL(b, 10); + d = RL(d + f2(e,a,b) + X[ 4] + 0x5a827999, 6) + c; a = RL(a, 10); + c = RL(c + f2(d,e,a) + X[13] + 0x5a827999, 8) + b; e = RL(e, 10); + b = RL(b + f2(c,d,e) + X[ 1] + 0x5a827999, 13) + a; d = RL(d, 10); + a = RL(a + f2(b,c,d) + X[10] + 0x5a827999, 11) + e; c = RL(c, 10); + e = RL(e + f2(a,b,c) + X[ 6] + 0x5a827999, 9) + d; b = RL(b, 10); + d = RL(d + f2(e,a,b) + X[15] + 0x5a827999, 7) + c; a = RL(a, 10); + c = RL(c + f2(d,e,a) + X[ 3] + 0x5a827999, 15) + b; e = RL(e, 10); + b = RL(b + f2(c,d,e) + X[12] + 0x5a827999, 7) + a; d = RL(d, 10); + a = RL(a + f2(b,c,d) + X[ 0] + 0x5a827999, 12) + e; c = RL(c, 10); + e = RL(e + f2(a,b,c) + X[ 9] + 0x5a827999, 15) + d; b = RL(b, 10); + d = RL(d + f2(e,a,b) + X[ 5] + 0x5a827999, 9) + c; a = RL(a, 10); + c = RL(c + f2(d,e,a) + X[ 2] + 0x5a827999, 11) + b; e = RL(e, 10); + b = RL(b + f2(c,d,e) + X[14] + 0x5a827999, 7) + a; d = RL(d, 10); + a = RL(a + f2(b,c,d) + X[11] + 0x5a827999, 13) + e; c = RL(c, 10); + e = RL(e + f2(a,b,c) + X[ 8] + 0x5a827999, 12) + d; b = RL(b, 10); + + // right + ee = RL(ee + f4(aa,bb,cc) + X[ 6] + 0x5c4dd124, 9) + dd; bb = RL(bb, 10); + dd = RL(dd + f4(ee,aa,bb) + X[11] + 0x5c4dd124, 13) + cc; aa = RL(aa, 10); + cc = RL(cc + f4(dd,ee,aa) + X[ 3] + 0x5c4dd124, 15) + bb; ee = RL(ee, 10); + bb = RL(bb + f4(cc,dd,ee) + X[ 7] + 0x5c4dd124, 7) + aa; dd = RL(dd, 10); + aa = RL(aa + f4(bb,cc,dd) + X[ 0] + 0x5c4dd124, 12) + ee; cc = RL(cc, 10); + ee = RL(ee + f4(aa,bb,cc) + X[13] + 0x5c4dd124, 8) + dd; bb = RL(bb, 10); + dd = RL(dd + f4(ee,aa,bb) + X[ 5] + 0x5c4dd124, 9) + cc; aa = RL(aa, 10); + cc = RL(cc + f4(dd,ee,aa) + X[10] + 0x5c4dd124, 11) + bb; ee = RL(ee, 10); + bb = RL(bb + f4(cc,dd,ee) + X[14] + 0x5c4dd124, 7) + aa; dd = RL(dd, 10); + aa = RL(aa + f4(bb,cc,dd) + X[15] + 0x5c4dd124, 7) + ee; cc = RL(cc, 10); + ee = RL(ee + f4(aa,bb,cc) + X[ 8] + 0x5c4dd124, 12) + dd; bb = RL(bb, 10); + dd = RL(dd + f4(ee,aa,bb) + X[12] + 0x5c4dd124, 7) + cc; aa = RL(aa, 10); + cc = RL(cc + f4(dd,ee,aa) + X[ 4] + 0x5c4dd124, 6) + bb; ee = RL(ee, 10); + bb = RL(bb + f4(cc,dd,ee) + X[ 9] + 0x5c4dd124, 15) + aa; dd = RL(dd, 10); + aa = RL(aa + f4(bb,cc,dd) + X[ 1] + 0x5c4dd124, 13) + ee; cc = RL(cc, 10); + ee = RL(ee + f4(aa,bb,cc) + X[ 2] + 0x5c4dd124, 11) + dd; bb = RL(bb, 10); + + // + // Rounds 32-47 + // + // left + d = RL(d + f3(e,a,b) + X[ 3] + 0x6ed9eba1, 11) + c; a = RL(a, 10); + c = RL(c + f3(d,e,a) + X[10] + 0x6ed9eba1, 13) + b; e = RL(e, 10); + b = RL(b + f3(c,d,e) + X[14] + 0x6ed9eba1, 6) + a; d = RL(d, 10); + a = RL(a + f3(b,c,d) + X[ 4] + 0x6ed9eba1, 7) + e; c = RL(c, 10); + e = RL(e + f3(a,b,c) + X[ 9] + 0x6ed9eba1, 14) + d; b = RL(b, 10); + d = RL(d + f3(e,a,b) + X[15] + 0x6ed9eba1, 9) + c; a = RL(a, 10); + c = RL(c + f3(d,e,a) + X[ 8] + 0x6ed9eba1, 13) + b; e = RL(e, 10); + b = RL(b + f3(c,d,e) + X[ 1] + 0x6ed9eba1, 15) + a; d = RL(d, 10); + a = RL(a + f3(b,c,d) + X[ 2] + 0x6ed9eba1, 14) + e; c = RL(c, 10); + e = RL(e + f3(a,b,c) + X[ 7] + 0x6ed9eba1, 8) + d; b = RL(b, 10); + d = RL(d + f3(e,a,b) + X[ 0] + 0x6ed9eba1, 13) + c; a = RL(a, 10); + c = RL(c + f3(d,e,a) + X[ 6] + 0x6ed9eba1, 6) + b; e = RL(e, 10); + b = RL(b + f3(c,d,e) + X[13] + 0x6ed9eba1, 5) + a; d = RL(d, 10); + a = RL(a + f3(b,c,d) + X[11] + 0x6ed9eba1, 12) + e; c = RL(c, 10); + e = RL(e + f3(a,b,c) + X[ 5] + 0x6ed9eba1, 7) + d; b = RL(b, 10); + d = RL(d + f3(e,a,b) + X[12] + 0x6ed9eba1, 5) + c; a = RL(a, 10); + + // right + dd = RL(dd + f3(ee,aa,bb) + X[15] + 0x6d703ef3, 9) + cc; aa = RL(aa, 10); + cc = RL(cc + f3(dd,ee,aa) + X[ 5] + 0x6d703ef3, 7) + bb; ee = RL(ee, 10); + bb = RL(bb + f3(cc,dd,ee) + X[ 1] + 0x6d703ef3, 15) + aa; dd = RL(dd, 10); + aa = RL(aa + f3(bb,cc,dd) + X[ 3] + 0x6d703ef3, 11) + ee; cc = RL(cc, 10); + ee = RL(ee + f3(aa,bb,cc) + X[ 7] + 0x6d703ef3, 8) + dd; bb = RL(bb, 10); + dd = RL(dd + f3(ee,aa,bb) + X[14] + 0x6d703ef3, 6) + cc; aa = RL(aa, 10); + cc = RL(cc + f3(dd,ee,aa) + X[ 6] + 0x6d703ef3, 6) + bb; ee = RL(ee, 10); + bb = RL(bb + f3(cc,dd,ee) + X[ 9] + 0x6d703ef3, 14) + aa; dd = RL(dd, 10); + aa = RL(aa + f3(bb,cc,dd) + X[11] + 0x6d703ef3, 12) + ee; cc = RL(cc, 10); + ee = RL(ee + f3(aa,bb,cc) + X[ 8] + 0x6d703ef3, 13) + dd; bb = RL(bb, 10); + dd = RL(dd + f3(ee,aa,bb) + X[12] + 0x6d703ef3, 5) + cc; aa = RL(aa, 10); + cc = RL(cc + f3(dd,ee,aa) + X[ 2] + 0x6d703ef3, 14) + bb; ee = RL(ee, 10); + bb = RL(bb + f3(cc,dd,ee) + X[10] + 0x6d703ef3, 13) + aa; dd = RL(dd, 10); + aa = RL(aa + f3(bb,cc,dd) + X[ 0] + 0x6d703ef3, 13) + ee; cc = RL(cc, 10); + ee = RL(ee + f3(aa,bb,cc) + X[ 4] + 0x6d703ef3, 7) + dd; bb = RL(bb, 10); + dd = RL(dd + f3(ee,aa,bb) + X[13] + 0x6d703ef3, 5) + cc; aa = RL(aa, 10); + + // + // Rounds 48-63 + // + // left + c = RL(c + f4(d,e,a) + X[ 1] + 0x8f1bbcdc, 11) + b; e = RL(e, 10); + b = RL(b + f4(c,d,e) + X[ 9] + 0x8f1bbcdc, 12) + a; d = RL(d, 10); + a = RL(a + f4(b,c,d) + X[11] + 0x8f1bbcdc, 14) + e; c = RL(c, 10); + e = RL(e + f4(a,b,c) + X[10] + 0x8f1bbcdc, 15) + d; b = RL(b, 10); + d = RL(d + f4(e,a,b) + X[ 0] + 0x8f1bbcdc, 14) + c; a = RL(a, 10); + c = RL(c + f4(d,e,a) + X[ 8] + 0x8f1bbcdc, 15) + b; e = RL(e, 10); + b = RL(b + f4(c,d,e) + X[12] + 0x8f1bbcdc, 9) + a; d = RL(d, 10); + a = RL(a + f4(b,c,d) + X[ 4] + 0x8f1bbcdc, 8) + e; c = RL(c, 10); + e = RL(e + f4(a,b,c) + X[13] + 0x8f1bbcdc, 9) + d; b = RL(b, 10); + d = RL(d + f4(e,a,b) + X[ 3] + 0x8f1bbcdc, 14) + c; a = RL(a, 10); + c = RL(c + f4(d,e,a) + X[ 7] + 0x8f1bbcdc, 5) + b; e = RL(e, 10); + b = RL(b + f4(c,d,e) + X[15] + 0x8f1bbcdc, 6) + a; d = RL(d, 10); + a = RL(a + f4(b,c,d) + X[14] + 0x8f1bbcdc, 8) + e; c = RL(c, 10); + e = RL(e + f4(a,b,c) + X[ 5] + 0x8f1bbcdc, 6) + d; b = RL(b, 10); + d = RL(d + f4(e,a,b) + X[ 6] + 0x8f1bbcdc, 5) + c; a = RL(a, 10); + c = RL(c + f4(d,e,a) + X[ 2] + 0x8f1bbcdc, 12) + b; e = RL(e, 10); + + // right + cc = RL(cc + f2(dd,ee,aa) + X[ 8] + 0x7a6d76e9, 15) + bb; ee = RL(ee, 10); + bb = RL(bb + f2(cc,dd,ee) + X[ 6] + 0x7a6d76e9, 5) + aa; dd = RL(dd, 10); + aa = RL(aa + f2(bb,cc,dd) + X[ 4] + 0x7a6d76e9, 8) + ee; cc = RL(cc, 10); + ee = RL(ee + f2(aa,bb,cc) + X[ 1] + 0x7a6d76e9, 11) + dd; bb = RL(bb, 10); + dd = RL(dd + f2(ee,aa,bb) + X[ 3] + 0x7a6d76e9, 14) + cc; aa = RL(aa, 10); + cc = RL(cc + f2(dd,ee,aa) + X[11] + 0x7a6d76e9, 14) + bb; ee = RL(ee, 10); + bb = RL(bb + f2(cc,dd,ee) + X[15] + 0x7a6d76e9, 6) + aa; dd = RL(dd, 10); + aa = RL(aa + f2(bb,cc,dd) + X[ 0] + 0x7a6d76e9, 14) + ee; cc = RL(cc, 10); + ee = RL(ee + f2(aa,bb,cc) + X[ 5] + 0x7a6d76e9, 6) + dd; bb = RL(bb, 10); + dd = RL(dd + f2(ee,aa,bb) + X[12] + 0x7a6d76e9, 9) + cc; aa = RL(aa, 10); + cc = RL(cc + f2(dd,ee,aa) + X[ 2] + 0x7a6d76e9, 12) + bb; ee = RL(ee, 10); + bb = RL(bb + f2(cc,dd,ee) + X[13] + 0x7a6d76e9, 9) + aa; dd = RL(dd, 10); + aa = RL(aa + f2(bb,cc,dd) + X[ 9] + 0x7a6d76e9, 12) + ee; cc = RL(cc, 10); + ee = RL(ee + f2(aa,bb,cc) + X[ 7] + 0x7a6d76e9, 5) + dd; bb = RL(bb, 10); + dd = RL(dd + f2(ee,aa,bb) + X[10] + 0x7a6d76e9, 15) + cc; aa = RL(aa, 10); + cc = RL(cc + f2(dd,ee,aa) + X[14] + 0x7a6d76e9, 8) + bb; ee = RL(ee, 10); + + // + // Rounds 64-79 + // + // left + b = RL(b + f5(c,d,e) + X[ 4] + 0xa953fd4e, 9) + a; d = RL(d, 10); + a = RL(a + f5(b,c,d) + X[ 0] + 0xa953fd4e, 15) + e; c = RL(c, 10); + e = RL(e + f5(a,b,c) + X[ 5] + 0xa953fd4e, 5) + d; b = RL(b, 10); + d = RL(d + f5(e,a,b) + X[ 9] + 0xa953fd4e, 11) + c; a = RL(a, 10); + c = RL(c + f5(d,e,a) + X[ 7] + 0xa953fd4e, 6) + b; e = RL(e, 10); + b = RL(b + f5(c,d,e) + X[12] + 0xa953fd4e, 8) + a; d = RL(d, 10); + a = RL(a + f5(b,c,d) + X[ 2] + 0xa953fd4e, 13) + e; c = RL(c, 10); + e = RL(e + f5(a,b,c) + X[10] + 0xa953fd4e, 12) + d; b = RL(b, 10); + d = RL(d + f5(e,a,b) + X[14] + 0xa953fd4e, 5) + c; a = RL(a, 10); + c = RL(c + f5(d,e,a) + X[ 1] + 0xa953fd4e, 12) + b; e = RL(e, 10); + b = RL(b + f5(c,d,e) + X[ 3] + 0xa953fd4e, 13) + a; d = RL(d, 10); + a = RL(a + f5(b,c,d) + X[ 8] + 0xa953fd4e, 14) + e; c = RL(c, 10); + e = RL(e + f5(a,b,c) + X[11] + 0xa953fd4e, 11) + d; b = RL(b, 10); + d = RL(d + f5(e,a,b) + X[ 6] + 0xa953fd4e, 8) + c; a = RL(a, 10); + c = RL(c + f5(d,e,a) + X[15] + 0xa953fd4e, 5) + b; e = RL(e, 10); + b = RL(b + f5(c,d,e) + X[13] + 0xa953fd4e, 6) + a; d = RL(d, 10); + + // right + bb = RL(bb + f1(cc,dd,ee) + X[12], 8) + aa; dd = RL(dd, 10); + aa = RL(aa + f1(bb,cc,dd) + X[15], 5) + ee; cc = RL(cc, 10); + ee = RL(ee + f1(aa,bb,cc) + X[10], 12) + dd; bb = RL(bb, 10); + dd = RL(dd + f1(ee,aa,bb) + X[ 4], 9) + cc; aa = RL(aa, 10); + cc = RL(cc + f1(dd,ee,aa) + X[ 1], 12) + bb; ee = RL(ee, 10); + bb = RL(bb + f1(cc,dd,ee) + X[ 5], 5) + aa; dd = RL(dd, 10); + aa = RL(aa + f1(bb,cc,dd) + X[ 8], 14) + ee; cc = RL(cc, 10); + ee = RL(ee + f1(aa,bb,cc) + X[ 7], 6) + dd; bb = RL(bb, 10); + dd = RL(dd + f1(ee,aa,bb) + X[ 6], 8) + cc; aa = RL(aa, 10); + cc = RL(cc + f1(dd,ee,aa) + X[ 2], 13) + bb; ee = RL(ee, 10); + bb = RL(bb + f1(cc,dd,ee) + X[13], 6) + aa; dd = RL(dd, 10); + aa = RL(aa + f1(bb,cc,dd) + X[14], 5) + ee; cc = RL(cc, 10); + ee = RL(ee + f1(aa,bb,cc) + X[ 0], 15) + dd; bb = RL(bb, 10); + dd = RL(dd + f1(ee,aa,bb) + X[ 3], 13) + cc; aa = RL(aa, 10); + cc = RL(cc + f1(dd,ee,aa) + X[ 9], 11) + bb; ee = RL(ee, 10); + bb = RL(bb + f1(cc,dd,ee) + X[11], 11) + aa; dd = RL(dd, 10); + + dd += c + H1; + H1 = H2 + d + ee; + H2 = H3 + e + aa; + H3 = H4 + a + bb; + H4 = H0 + b + cc; + H0 = dd; + + // + // reset the offset and clean out the word buffer. + // + xOff = 0; + for (int i = 0; i != X.length; i++) + { + X[i] = 0; + } + } + + public Memoable copy() + { + return new RIPEMD160Digest(this); + } + + public void reset(Memoable other) + { + RIPEMD160Digest d = (RIPEMD160Digest)other; + + copyIn(d); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/CommitmentNotFoundException.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/CommitmentNotFoundException.java new file mode 100644 index 000000000..14e3fd457 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/CommitmentNotFoundException.java @@ -0,0 +1,7 @@ +package com.vitorpamplona.quartz.ots.exceptions; + +public class CommitmentNotFoundException extends Exception { + public CommitmentNotFoundException(String message) { + super(message); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/DeserializationException.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/DeserializationException.java new file mode 100644 index 000000000..76da9998b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/DeserializationException.java @@ -0,0 +1,7 @@ +package com.vitorpamplona.quartz.ots.exceptions; + +public class DeserializationException extends Exception { + public DeserializationException(String message) { + super(message); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/ExceededSizeException.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/ExceededSizeException.java new file mode 100644 index 000000000..5a921de9d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/ExceededSizeException.java @@ -0,0 +1,7 @@ +package com.vitorpamplona.quartz.ots.exceptions; + +public class ExceededSizeException extends Exception { + public ExceededSizeException(String message) { + super(message); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/UrlException.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/UrlException.java new file mode 100644 index 000000000..a3d56c192 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/UrlException.java @@ -0,0 +1,7 @@ +package com.vitorpamplona.quartz.ots.exceptions; + +public class UrlException extends Exception { + public UrlException(String message) { + super(message); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/VerificationException.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/VerificationException.java new file mode 100644 index 000000000..7d53dfedb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/exceptions/VerificationException.java @@ -0,0 +1,7 @@ +package com.vitorpamplona.quartz.ots.exceptions; + +public class VerificationException extends Exception { + public VerificationException(String message) { + super(message); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/http/Request.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/http/Request.java new file mode 100644 index 000000000..0df2db21d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/http/Request.java @@ -0,0 +1,94 @@ +package com.vitorpamplona.quartz.ots.http; + +import android.util.Log; + +import java.io.DataOutputStream; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.zip.GZIPInputStream; + +/** + * For making an HTTP request. + */ +public class Request implements Callable { + private URL url; + private byte[] data; + private Map headers; + private BlockingQueue queue; + + public Request(URL url) { + this.url = url; + } + + public void setData(byte[] data) { + this.data = data; + } + + public void setHeaders(Map headers) { + this.headers = headers; + } + + public void setQueue(BlockingQueue queue) { + this.queue = queue; + } + + @Override + public Response call() throws Exception { + Response response = new Response(); + + try { + HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); + httpURLConnection.setReadTimeout(10000); + httpURLConnection.setConnectTimeout(10000); + httpURLConnection.setRequestProperty("User-Agent", "OpenTimestamps Java"); + httpURLConnection.setRequestProperty("Accept", "application/json"); + httpURLConnection.setRequestProperty("Accept-Encoding", "gzip"); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + httpURLConnection.setRequestProperty(entry.getKey(), entry.getValue()); + } + } + + if (data != null) { + httpURLConnection.setDoOutput(true); + httpURLConnection.setRequestMethod("POST"); + httpURLConnection.setRequestProperty("Content-Length", "" + Integer.toString(this.data.length)); + DataOutputStream wr = new DataOutputStream(httpURLConnection.getOutputStream()); + wr.write(this.data, 0, this.data.length); + wr.flush(); + wr.close(); + } else { + httpURLConnection.setRequestMethod("GET"); + } + + httpURLConnection.connect(); + + int responseCode = httpURLConnection.getResponseCode(); + + Log.i("OpenTimestamp", responseCode + " responseCode "); + + response.setStatus(responseCode); + response.setFromUrl(url.toString()); + InputStream is = httpURLConnection.getInputStream(); + if ("gzip".equals(httpURLConnection.getContentEncoding())) { + is = new GZIPInputStream(is); + } + response.setStream(is); + } catch (Exception e) { + Log.w("OpenTimestamp", url.toString() + " exception " + e); + } finally { + if (queue != null) { + queue.offer(response); + } + } + + return response; + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/http/Response.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/http/Response.java new file mode 100644 index 000000000..adb378f38 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/http/Response.java @@ -0,0 +1,77 @@ +package com.vitorpamplona.quartz.ots.http; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * Holds the response from an HTTP request. + */ +public class Response { + private InputStream stream; + private String fromUrl; + private Integer status; + + public Response() { + } + + public Response(InputStream stream) { + this.stream = stream; + } + + public void setStream(InputStream stream) { + this.stream = stream; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public boolean isOk() { + return getStatus() != null && 200 == getStatus(); + } + + public String getFromUrl() { + return fromUrl; + } + + public void setFromUrl(String fromUrl) { + this.fromUrl = fromUrl; + } + + public InputStream getStream() { + return this.stream; + } + + public String getString() throws IOException { + return new String(getBytes(), StandardCharsets.UTF_8); + } + + public byte[] getBytes() throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int nRead; + byte[] data = new byte[16384]; + + while ((nRead = this.stream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + + buffer.flush(); + + return buffer.toByteArray(); + } + + public JsonNode getJson() throws IOException { + String jsonString = getString(); + JsonMapper builder = JsonMapper.builder().build(); + return builder.readTree(jsonString); + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/Op.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/Op.java new file mode 100644 index 000000000..9dc7700a3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/Op.java @@ -0,0 +1,139 @@ +package com.vitorpamplona.quartz.ots.op; + +import android.util.Log; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.logging.Logger; + +/** + * Operations are the edges in the timestamp tree, with each operation taking a message and zero or more arguments to produce a result. + */ +public abstract class Op implements Comparable { + + /** + * Maximum length of an com.vitorpamplona.quartz.ots.op.Op result + *

+ * For a verifier, this limit is what limits the maximum amount of memory you + * need at any one time to verify a particular timestamp path; while verifying + * a particular commitment operation path previously calculated results can be + * discarded. + *

+ * Of course, if everything was a merkle tree you never need to append/prepend + * anything near 4KiB of data; 64 bytes would be plenty even with SHA512. The + * main need for this is compatibility with existing systems like Bitcoin + * timestamps and Certificate Transparency servers. While the pathological + * limits required by both are quite large - 1MB and 16MiB respectively - 4KiB + * is perfectly adequate in both cases for more reasonable usage. + *

+ * @see Op subclasses should set this limit even lower if doing so is appropriate + * for them. + */ + public static int _MAX_RESULT_LENGTH = 4096; + + /** + * Maximum length of the message an com.vitorpamplona.quartz.ots.op.Op can be applied too. + *

+ * Similar to the result length limit, this limit gives implementations a sane + * constraint to work with; the maximum result-length limit implicitly + * constrains maximum message length anyway. + *

+ * com.vitorpamplona.quartz.ots.op.Op subclasses should set this limit even lower if doing so is appropriate + * for them. + */ + public static int _MAX_MSG_LENGTH = 4096; + + public static byte _TAG = (byte) 0x00; + + public String _TAG_NAME() { + return ""; + } + + public byte _TAG() { + return Op._TAG; + } + + /** + * Deserialize operation from a buffer. + * + * @param ctx The stream deserialization context. + * @return The subclass Operation. + */ + public static Op deserialize(StreamDeserializationContext ctx) throws DeserializationException { + byte tag = ctx.readBytes(1)[0]; + + return Op.deserializeFromTag(ctx, tag); + } + + /** + * Deserialize operation from a buffer. + * + * @param ctx The stream deserialization context. + * @param tag The tag of the operation. + * @return The subclass Operation. + */ + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) + throws DeserializationException { + if (tag == OpAppend._TAG) { + return OpAppend.deserializeFromTag(ctx, tag); + } else if (tag == OpPrepend._TAG) { + return OpPrepend.deserializeFromTag(ctx, tag); + } else if (tag == OpSHA1._TAG) { + return OpSHA1.deserializeFromTag(ctx, tag); + } else if (tag == OpSHA256._TAG) { + return OpSHA256.deserializeFromTag(ctx, tag); + } else if (tag == OpRIPEMD160._TAG) { + return OpRIPEMD160.deserializeFromTag(ctx, tag); + } else if (tag == OpKECCAK256._TAG) { + return OpKECCAK256.deserializeFromTag(ctx, tag); + } else { + Log.e("OpenTimestamp", "Unknown operation tag: " + tag + " 0x" + String.format("%02x", tag)); + return null; // TODO: Is this OK? Won't it blow up later? Better to throw? + } + } + + /** + * Serialize operation. + * + * @param ctx The stream serialization context. + */ + public void serialize(StreamSerializationContext ctx) { + if (this._TAG() == 0x00) { + Log.e("OpenTimestamp", "No valid serialized Op"); + // TODO: Is it OK to just log and carry on? Won't it blow up later? Better to throw? + } + + ctx.writeByte(this._TAG()); + } + + /** + * Apply the operation to a message. + * Raises MsgValueError if the message value is invalid, such as it being + * too long, or it causing the result to be too long. + * + * @param msg The message. + * @return the msg after the operation has been applied + */ + public byte[] call(byte[] msg) { + if (msg.length > _MAX_MSG_LENGTH) { + Log.e("OpenTimestamp", "Error : Message too long;"); + return new byte[]{}; // TODO: Is this OK? Won't it blow up later? Better to throw? + } + + byte[] r = this.call(msg); + + if (r.length > _MAX_RESULT_LENGTH) { + Log.e("OpenTimestamp", "Error : Result too long;"); + // TODO: Is it OK to just log and carry on? Won't it blow up later? Better to throw? + } + + return r; + } + + @Override + public int compareTo(Op o) { + return this._TAG() - o._TAG(); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpAppend.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpAppend.java new file mode 100644 index 000000000..0b81fe726 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpAppend.java @@ -0,0 +1,63 @@ +package com.vitorpamplona.quartz.ots.op; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Append a suffix to a message. + * + * @see OpBinary + */ +public class OpAppend extends OpBinary { + byte[] arg; + + public static byte _TAG = (byte) 0xf0; + + @Override + public byte _TAG() { + return OpAppend._TAG; + } + + @Override + public String _TAG_NAME() { + return "append"; + } + + public OpAppend() { + super(); + this.arg = new byte[]{}; + } + + public OpAppend(byte[] arg_) { + super(arg_); + this.arg = arg_; + } + + @Override + public byte[] call(byte[] msg) { + return Utils.arraysConcat(msg, this.arg); + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) + throws DeserializationException { + return OpBinary.deserializeFromTag(ctx, tag); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof OpAppend)) { + return false; + } + + return Arrays.equals(this.arg, ((OpAppend) obj).arg); + } + + @Override + public int hashCode() { + return _TAG ^ Arrays.hashCode(this.arg); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpBinary.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpBinary.java new file mode 100644 index 000000000..217337e50 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpBinary.java @@ -0,0 +1,75 @@ +package com.vitorpamplona.quartz.ots.op; + +import android.util.Log; + +import com.vitorpamplona.quartz.encoders.Hex; +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.StreamSerializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.Arrays; + +/** + * Operations that act on a message and a single argument. + * + * @see OpUnary + */ +public abstract class OpBinary extends Op implements Comparable { + + public byte[] arg; + + @Override + public String _TAG_NAME() { + return ""; + } + + public OpBinary() { + super(); + this.arg = new byte[]{}; + } + + public OpBinary(byte[] arg_) { + super(); + this.arg = arg_; + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) + throws DeserializationException { + byte[] arg = ctx.readVarbytes(_MAX_RESULT_LENGTH, 1); + + if (tag == OpAppend._TAG) { + return new OpAppend(arg); + } else if (tag == OpPrepend._TAG) { + return new OpPrepend(arg); + } else { + Log.e("OpenTimestamp", "Unknown operation tag: " + tag + " 0x" + String.format("%02x", tag)); + return null; // TODO: Is this OK? Won't it blow up later? Better to throw? + } + } + + @Override + public void serialize(StreamSerializationContext ctx) { + super.serialize(ctx); + ctx.writeVarbytes(this.arg); + } + + @Override + public String toString() { + return this._TAG_NAME() + ' ' + Hex.encode(this.arg).toLowerCase(); + } + + @Override + public int compareTo(Op o) { + if (o instanceof OpBinary && this._TAG() == o._TAG()) { + return Utils.compare(this.arg, ((OpBinary) o).arg); + } + + return this._TAG() - o._TAG(); + } + + @Override + public int hashCode() { + return _TAG ^ Arrays.hashCode(this.arg); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpCrypto.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpCrypto.java new file mode 100644 index 000000000..28e6b1aaa --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpCrypto.java @@ -0,0 +1,97 @@ +package com.vitorpamplona.quartz.ots.op; + +import android.util.Log; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Cryptographic transformations. + * These transformations have the unique property that for any length message, + * the size of the result they return is fixed. Additionally, they're the only + * type of operation that can be applied directly to a stream. + * + * @see OpUnary + */ +public class OpCrypto extends OpUnary { + + public String _TAG_NAME = ""; + + public String _HASHLIB_NAME() { + return ""; + } + + public int _DIGEST_LENGTH() { + return 0; + } + + OpCrypto() { + super(); + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) { + return OpUnary.deserializeFromTag(ctx, tag); + } + + @Override + public byte[] call(byte[] msg) { + // For Sha1 & Sha256 use java.security.MessageDigest library + try { + MessageDigest digest = MessageDigest.getInstance(this._HASHLIB_NAME()); + byte[] hash = digest.digest(msg); + + return hash; + } catch (NoSuchAlgorithmException e) { + Log.e("OpenTimestamp", "NoSuchAlgorithmException"); + e.printStackTrace(); + + return new byte[]{}; // TODO: Is this OK? Won't it blow up later? Better to throw? + } + } + + public byte[] hashFd(StreamDeserializationContext ctx) throws NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance(this._HASHLIB_NAME()); + byte[] chunk = ctx.read(1048576); + + while (chunk != null && chunk.length > 0) { + digest.update(chunk); + chunk = ctx.read(1048576); + } + + byte[] hash = digest.digest(); + + return hash; + } + + public byte[] hashFd(File file) throws IOException, NoSuchAlgorithmException { + return hashFd(new FileInputStream(file)); + } + + public byte[] hashFd(byte[] bytes) throws IOException, NoSuchAlgorithmException { + StreamDeserializationContext ctx = new StreamDeserializationContext(bytes); + + return hashFd(ctx); + } + + public byte[] hashFd(InputStream inputStream) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance(this._HASHLIB_NAME()); + byte[] chunk = new byte[1048576]; + int count = inputStream.read(chunk, 0, 1048576); + + while (count >= 0) { + digest.update(chunk, 0, count); + count = inputStream.read(chunk, 0, 1048576); + } + + inputStream.close(); + byte[] hash = digest.digest(); + + return hash; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpKECCAK256.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpKECCAK256.java new file mode 100644 index 000000000..c77582759 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpKECCAK256.java @@ -0,0 +1,62 @@ +package com.vitorpamplona.quartz.ots.op; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; +import com.vitorpamplona.quartz.ots.crypto.KeccakDigest; + +import java.util.logging.Logger; + +/** + * Cryptographic Keccak256 operation. + * Cryptographic operation tag numbers taken from RFC4880, although it's not + * guaranteed that they'll continue to match that RFC in the future. + * + * @see OpCrypto + */ +public class OpKECCAK256 extends OpCrypto { + private KeccakDigest digest = new KeccakDigest(256); + + public static byte _TAG = (byte) 103; + + @Override + public byte _TAG() { + return OpKECCAK256._TAG; + } + + @Override + public String _TAG_NAME() { + return "keccak256"; + } + + @Override + public String _HASHLIB_NAME() { + return "keccak256"; + } + + @Override + public int _DIGEST_LENGTH() { + return digest.getDigestSize(); + } + + public OpKECCAK256() { + super(); + } + + @Override + public byte[] call(byte[] msg) { + digest.update(msg, 0, msg.length); + byte[] hash = new byte[digest.getDigestSize()]; + digest.doFinal(hash, 0); + + return hash; + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) { + return OpCrypto.deserializeFromTag(ctx, tag); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof OpKECCAK256); + } +} \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpPrepend.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpPrepend.java new file mode 100644 index 000000000..89c3d89e5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpPrepend.java @@ -0,0 +1,64 @@ +package com.vitorpamplona.quartz.ots.op; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import com.vitorpamplona.quartz.ots.exceptions.DeserializationException; +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Prepend a prefix to a message. + * + * @see OpBinary + */ +public class OpPrepend extends OpBinary { + + byte[] arg; + + public static byte _TAG = (byte) 0xf1; + + @Override + public byte _TAG() { + return OpPrepend._TAG; + } + + @Override + public String _TAG_NAME() { + return "prepend"; + } + + public OpPrepend() { + super(); + this.arg = new byte[]{}; + } + + public OpPrepend(byte[] arg_) { + super(arg_); + this.arg = arg_; + } + + @Override + public byte[] call(byte[] msg) { + return Utils.arraysConcat(this.arg, msg); + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) + throws DeserializationException { + return OpBinary.deserializeFromTag(ctx, tag); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof OpPrepend)) { + return false; + } + + return Arrays.equals(this.arg, ((OpPrepend) obj).arg); + } + + @Override + public int hashCode() { + return _TAG ^ Arrays.hashCode(this.arg); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpRIPEMD160.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpRIPEMD160.java new file mode 100644 index 000000000..cf89834fd --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpRIPEMD160.java @@ -0,0 +1,67 @@ +package com.vitorpamplona.quartz.ots.op; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; +import com.vitorpamplona.quartz.ots.crypto.RIPEMD160Digest; + +import java.util.logging.Logger; + +/** + * Cryptographic RIPEMD160 operation. + * Cryptographic operation tag numbers taken from RFC4880, although it's not + * guaranteed that they'll continue to match that RFC in the future. + * + * @see OpCrypto + */ +public class OpRIPEMD160 extends OpCrypto { + + public static byte _TAG = 0x03; + + @Override + public byte _TAG() { + return OpRIPEMD160._TAG; + } + + @Override + public String _TAG_NAME() { + return "ripemd160"; + } + + @Override + public String _HASHLIB_NAME() { + return "ripemd160"; + } + + @Override + public int _DIGEST_LENGTH() { + return 20; + } + + public OpRIPEMD160() { + super(); + } + + @Override + public byte[] call(byte[] msg) { + RIPEMD160Digest digest = new RIPEMD160Digest(); + digest.update(msg, 0, msg.length); + byte[] hash = new byte[digest.getDigestSize()]; + digest.doFinal(hash, 0); + + return hash; + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) { + return OpCrypto.deserializeFromTag(ctx, tag); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof OpRIPEMD160); + } + + @Override + public int hashCode() { + return _TAG; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpSHA1.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpSHA1.java new file mode 100644 index 000000000..0a8080090 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpSHA1.java @@ -0,0 +1,68 @@ +package com.vitorpamplona.quartz.ots.op; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import java.util.logging.Logger; + +/** + * Cryptographic SHA1 operation. + * Cryptographic operation tag numbers taken from RFC4880, although it's not + * guaranteed that they'll continue to match that RFC in the future. + * Remember that for timestamping, hash algorithms with collision attacks + * *are* secure! We've still proven that both messages existed prior to some + * point in time - the fact that they both have the same hash digest doesn't + * change that. + * Heck, even md5 is still secure enough for timestamping... but that's + * pushing our luck... + * + * @see OpCrypto + */ +public class OpSHA1 extends OpCrypto { + + + public static byte _TAG = 0x02; + + @Override + public byte _TAG() { + return OpSHA1._TAG; + } + + @Override + public String _TAG_NAME() { + return "sha1"; + } + + @Override + public String _HASHLIB_NAME() { + return "SHA-1"; + } + + @Override + public int _DIGEST_LENGTH() { + return 20; + } + + public OpSHA1() { + super(); + } + + @Override + public byte[] call(byte[] msg) { + return super.call(msg); + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) { + return OpCrypto.deserializeFromTag(ctx, tag); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof OpSHA1); + } + + @Override + public int hashCode() { + return _TAG; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpSHA256.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpSHA256.java new file mode 100644 index 000000000..892713a46 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpSHA256.java @@ -0,0 +1,62 @@ +package com.vitorpamplona.quartz.ots.op; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import java.util.logging.Logger; + +/** + * Cryptographic SHA256 operation. + * Cryptographic operation tag numbers taken from RFC4880, although it's not + * guaranteed that they'll continue to match that RFC in the future. + * + * @see OpCrypto + */ +public class OpSHA256 extends OpCrypto { + + + public static byte _TAG = 0x08; + + @Override + public byte _TAG() { + return OpSHA256._TAG; + } + + @Override + public String _TAG_NAME() { + return "sha256"; + } + + @Override + public String _HASHLIB_NAME() { + return "SHA-256"; + } + + @Override + public int _DIGEST_LENGTH() { + return 32; + } + + public OpSHA256() { + super(); + } + + @Override + public byte[] call(byte[] msg) { + return super.call(msg); + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) { + return OpCrypto.deserializeFromTag(ctx, tag); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof OpSHA256); + } + + @Override + public int hashCode() { + return _TAG; + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpUnary.java b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpUnary.java new file mode 100644 index 000000000..d1b5894e1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/ots/op/OpUnary.java @@ -0,0 +1,46 @@ +package com.vitorpamplona.quartz.ots.op; + +import android.util.Log; + +import com.vitorpamplona.quartz.ots.StreamDeserializationContext; +import com.vitorpamplona.quartz.ots.Utils; + +import java.util.logging.Logger; + +/** + * Operations that act on a single message. + * + * @see Op + */ +public abstract class OpUnary extends Op { + + @Override + public String _TAG_NAME() { + return ""; + } + + public OpUnary() { + super(); + } + + public static Op deserializeFromTag(StreamDeserializationContext ctx, byte tag) { + if (tag == OpSHA1._TAG) { + return new OpSHA1(); + } else if (tag == OpSHA256._TAG) { + return new OpSHA256(); + } else if (tag == OpRIPEMD160._TAG) { + return new OpRIPEMD160(); + } else if (tag == OpKECCAK256._TAG) { + return new OpKECCAK256(); + } else { + Log.e("OpenTimestamp", "Unknown operation tag: " + tag); + + return null; // TODO: Is this OK? Won't it blow up later? Better to throw? + } + } + + @Override + public String toString() { + return this._TAG_NAME(); + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt index 7d5449ddb..bff1e2eb8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -34,9 +34,9 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.LnZapRequestEvent -import org.json.JSONArray enum class SignerType { SIGN_EVENT, @@ -86,15 +86,13 @@ class Result( fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) + /** + * Parses the json with a string of events to an Array of Event objects. + */ fun fromJsonArray(json: String): Array { - val result: MutableList = mutableListOf() - val array = JSONArray(json) - (0 until array.length()).forEach { - val resultJson = array.getJSONObject(it) - val localResult = fromJson(resultJson.toString()) - result.add(localResult) - } - return result.toTypedArray() + return Event.mapper.readTree(json).map { + fromJson(it.asText()) + }.toTypedArray() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt index cca9980e2..5014db2f2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt index 6952b8b98..4f34af999 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -150,15 +150,12 @@ class NostrSignerExternal( event: LnZapRequestEvent, onReady: (LnZapPrivateEvent) -> Unit, ) { - return launcher.decryptZapEvent(event) { - val event = - try { - Event.fromJson(it) - } catch (e: Exception) { - Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $it") - null - } - (event as? LnZapPrivateEvent)?.let { onReady(event) } + return launcher.decryptZapEvent(event) { jsonEvent -> + try { + (Event.fromJson(jsonEvent) as? LnZapPrivateEvent)?.let { onReady(it) } + } catch (e: Exception) { + Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $jsonEvent") + } } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt index 3a04392c9..2537287b5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt index a6f1e4947..5bf3e568a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index d5669942b..5216e8ca2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt index c7fadde3f..541a1d3c7 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt index 9bb72ac58..ee3fb557a 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in @@ -21,45 +21,47 @@ package com.vitorpamplona.quartz.encoders import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test class NIP19ParserTest { @Test fun nAddrParser() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", ) assertEquals( "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", - result?.hex, + (result?.entity as? Nip19Bech32.NAddress)?.atag, ) } @Test fun nAddrParser2() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", ) assertEquals( "30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", - result?.hex, + (result?.entity as? Nip19Bech32.NAddress)?.atag, ) } @Test fun nAddrParse3() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", ) - assertEquals(Nip19.Type.ADDRESS, result?.type) + assertTrue(result?.entity is Nip19Bech32.NAddress) assertEquals( "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - result?.hex, + (result?.entity as? Nip19Bech32.NAddress)?.atag, ) - assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("wss://relay.damus.io", (result?.entity as? Nip19Bech32.NAddress)?.relay?.get(0)) } @Test @@ -130,15 +132,16 @@ class NIP19ParserTest { @Test fun nAddrParserPablo() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) + )?.entity as? Nip19Bech32.NAddress + + assertNotNull(result) assertEquals( "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", - result?.hex, + result?.atag, ) - assertEquals(null, result?.relay) + assertEquals(true, result?.relay?.isEmpty()) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) assertEquals(31337, result?.kind) } @@ -146,15 +149,16 @@ class NIP19ParserTest { @Test fun nAddrParserGizmo() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) + )?.entity as? Nip19Bech32.NAddress + + assertNotNull(result) assertEquals( "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", - result?.hex, + result?.atag, ) - assertEquals(null, result?.relay) + assertEquals(true, result?.relay?.isEmpty()) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) assertEquals(30023, result?.kind) } @@ -162,15 +166,16 @@ class NIP19ParserTest { @Test fun nAddrParserGizmo2() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "naddr1qq9rzd3h8y6nqwf5xyuqygzxljlrqe027xh8sy2xtyjwfzfrxcll8afxh4hh847psjckhkxwf5psgqqqw4rsty50fx", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) + )?.entity as? Nip19Bech32.NAddress + + assertNotNull(result) assertEquals( "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", - result?.hex, + result?.atag, ) - assertEquals(null, result?.relay) + assertEquals(true, result?.relay?.isEmpty()) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) assertEquals(30023, result?.kind) } @@ -178,10 +183,11 @@ class NIP19ParserTest { @Test fun nEventParserTest() { val result = - Nip19.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy") - assertEquals(Nip19.Type.EVENT, result?.type) + Nip19Bech32.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")?.entity as? Nip19Bech32.NEvent + + assertNotNull(result) assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) - assertEquals(null, result?.relay) + assertEquals(true, result?.relay?.isEmpty()) assertEquals(null, result?.author) assertEquals(null, result?.kind) } @@ -189,12 +195,13 @@ class NIP19ParserTest { @Test fun nEventParser() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c", - ) - assertEquals(Nip19.Type.EVENT, result?.type) + )?.entity as? Nip19Bech32.NEvent + + assertNotNull(result) assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) - assertEquals("wss://nostr.mom", result?.relay) + assertEquals("wss://nostr.mom", result?.relay?.get(0)) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) assertEquals(1, result?.kind) } @@ -202,13 +209,13 @@ class NIP19ParserTest { @Test fun nEventParser2() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", - ) + )?.entity as? Nip19Bech32.NEvent - assertEquals(Nip19.Type.EVENT, result?.type) + assertNotNull(result) assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("wss://relay.damus.io", result?.relay?.get(0)) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -216,28 +223,28 @@ class NIP19ParserTest { @Test fun nEventParser3() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", - ) + )?.entity as? Nip19Bech32.NEvent - assertEquals(Nip19.Type.EVENT, result?.type) + assertNotNull(result) assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) assertEquals( "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", - result?.relay, + result?.relay?.joinToString(","), ) } @Test fun nEventParserInvalidChecksum() { val result = - Nip19.uriToRoute( + Nip19Bech32.uriToRoute( "nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj", - ) + )?.entity as? Nip19Bech32.NEvent - assertEquals(Nip19.Type.EVENT, result?.type) + assertNotNull(result) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("wss://relay.damus.io", result?.relay?.get(0)) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -245,7 +252,7 @@ class NIP19ParserTest { @Test fun nEventFormatter() { val nevent = - Nip19.createNEvent( + Nip19Bech32.createNEvent( "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", null, null, @@ -257,7 +264,7 @@ class NIP19ParserTest { @Test fun nEventFormatterWithExtraInfo() { val nevent = - Nip19.createNEvent( + Nip19Bech32.createNEvent( "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", 40, @@ -272,7 +279,7 @@ class NIP19ParserTest { @Test fun nEventFormatterWithFullInfo() { val nevent = - Nip19.createNEvent( + Nip19Bech32.createNEvent( "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", 1, diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Bech32Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Bech32Test.kt new file mode 100644 index 000000000..7d1cce5eb --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Bech32Test.kt @@ -0,0 +1,117 @@ +/** + * 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.quartz.encoders + +import org.junit.Assert +import org.junit.Assert.assertNotNull +import org.junit.Test + +class Nip19Bech32Test { + @Test() + fun uri_to_route_null() { + val actual = Nip19Bech32.uriToRoute(null) + + Assert.assertEquals(null, actual) + } + + @Test() + fun uri_to_route_unknown() { + val actual = Nip19Bech32.uriToRoute("nostr:unknown") + + Assert.assertEquals(null, actual) + } + + @Test() + fun uri_to_route_npub() { + val actual = + Nip19Bech32.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") + + Assert.assertTrue(actual?.entity is Nip19Bech32.NPub) + Assert.assertEquals( + "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", + (actual?.entity as? Nip19Bech32.NPub)?.hex, + ) + } + + @Test() + fun uri_to_route_note() { + val result = + Nip19Bech32.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv")?.entity as? Nip19Bech32.Note + + assertNotNull(result) + Assert.assertEquals( + "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", + result?.hex, + ) + } + + @Test() + fun uri_to_route_nprofile() { + val actual = Nip19Bech32.uriToRoute("nostr:nprofile") + + Assert.assertEquals(null, actual) + } + + @Test() + fun uri_to_route_incomplete_nevent() { + val actual = Nip19Bech32.uriToRoute("nostr:nevent") + + Assert.assertEquals(null, actual) + } + + @Test() + fun uri_to_route_incomplete_nrelay() { + val actual = Nip19Bech32.uriToRoute("nostr:nrelay") + + Assert.assertEquals(null, actual) + } + + @Test() + fun uri_to_route_incomplete_naddr() { + val actual = Nip19Bech32.uriToRoute("nostr:naddr") + + Assert.assertEquals(null, actual) + } + + @Test() + fun uri_to_route_complete_nprofile() { + val actual = Nip19Bech32.uriToRoute("nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqzq9thu3vem5gvsc6f3l3uyz7c92h6lq56t9wws0zulzkrgc6nrvym5jfztf") + + Assert.assertTrue(actual?.entity is Nip19Bech32.NProfile) + Assert.assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", (actual?.entity as? Nip19Bech32.NProfile)?.hex) + } + + @Test() + fun uri_to_route_complete_nevent() { + val actual = Nip19Bech32.uriToRoute("nostr:nevent1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq36amnwvaz7tmjv4kxz7fwvd5xjcmpvahhqmr9vfejucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshsz8thwden5te0dehhxarj9ekh2arfdeuhwctvd3jhgtnrdakj7qg3waehxw309ucngvpwvcmh5tnfduhszythwden5te0dehhxarj9emkjmn99uq3jamnwvaz7tmhv4kxxmmdv5hxummnw3ezuamfdejj7qpqvsup5xk3e2quedxjvn2gjppc0lqny5dmnr2ypc9tftwmdxta0yjqrd6n50") + + Assert.assertTrue(actual?.entity is Nip19Bech32.NEvent) + Assert.assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", (actual?.entity as? Nip19Bech32.NEvent)?.hex) + } + + @Test() + fun uri_to_route_complete_naddr() { + val actual = Nip19Bech32.uriToRoute("nostr:naddr1qqyxzmt9w358jum5qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypd7v3r24z33cydnk3fmlrd0exe5dlej3506zxs05q4puerp765mzqcyqqq8scsq6mk7u") + + Assert.assertTrue(actual?.entity is Nip19Bech32.NAddress) + Assert.assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", (actual?.entity as? Nip19Bech32.NAddress)?.atag) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt deleted file mode 100644 index f807527a6..000000000 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt +++ /dev/null @@ -1,101 +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.quartz.encoders - -import org.junit.Assert -import org.junit.Ignore -import org.junit.Test - -class Nip19Test { - @Test() - fun uri_to_route_null() { - val actual = Nip19.uriToRoute(null) - - Assert.assertEquals(null, actual) - } - - @Test() - fun uri_to_route_unknown() { - val actual = Nip19.uriToRoute("nostr:unknown") - - Assert.assertEquals(null, actual) - } - - @Test() - fun uri_to_route_npub() { - val actual = - Nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") - - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals( - "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", - actual?.hex, - ) - } - - @Test() - fun uri_to_route_note() { - val actual = - Nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") - - Assert.assertEquals(Nip19.Type.NOTE, actual?.type) - Assert.assertEquals( - "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", - actual?.hex, - ) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nprofile() { - val actual = Nip19.uriToRoute("nostr:nprofile") - - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nevent() { - val actual = Nip19.uriToRoute("nostr:nevent") - - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nrelay() { - val actual = Nip19.uriToRoute("nostr:nrelay") - - Assert.assertEquals(Nip19.Type.RELAY, actual?.type) - Assert.assertEquals("*", actual?.hex) - } - - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_naddr() { - val actual = Nip19.uriToRoute("nostr:naddr") - - Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) - Assert.assertEquals("*", actual?.hex) - } -} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip30Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip30Test.kt new file mode 100644 index 000000000..eb99cb9ae --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip30Test.kt @@ -0,0 +1,168 @@ +/** + * 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.quartz.encoders + +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import org.junit.Test + +class Nip30Test { + @Test() + fun parseEmoji() { + val tags = mapOf(":soapbox:" to "http://soapbox") + val input = "Alex Gleason :soapbox:" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(2, result!!.size) + + assertEquals( + "Alex Gleason ", + (result!![0] as Nip30CustomEmoji.TextType).text, + ) + + assertEquals( + "http://soapbox", + (result!![1] as Nip30CustomEmoji.ImageUrlType).url, + ) + } + + @Test() + fun parseEmojiInverted() { + val tags = mapOf(":soapbox:" to "http://soapbox") + val input = ":soapbox:Alex Gleason" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(2, result!!.size) + + assertEquals( + "http://soapbox", + (result!![0] as Nip30CustomEmoji.ImageUrlType).url, + ) + + assertEquals( + "Alex Gleason", + (result!![1] as Nip30CustomEmoji.TextType).text, + ) + } + + @Test() + fun parseEmoji2() { + val tags = + mapOf( + ":gleasonator:" to "http://gleasonator", + ":ablobcatrainbow:" to "http://ablobcatrainbow", + ":disputed:" to "http://disputed", + ) + val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(7, result!!.size) + + assertEquals("Hello ", (result!![0] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://gleasonator", (result!![1] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" 😂 ", (result!![2] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://ablobcatrainbow", (result!![3] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" ", (result!![4] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://disputed", (result!![5] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" yolo", (result!![6] as Nip30CustomEmoji.TextType).text) + } + + @Test() + fun parseEmoji3() { + val tags = emptyMap() + val input = "hello vitor: how can I help:" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertNull(result) + } + + @Test() + fun parseEmoji4() { + val tags = mapOf(":vitor:" to "http://vitor") + val input = "hello :vitor: how :can I help:" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(3, result!!.size) + + assertEquals("hello ", (result!![0] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://vitor", (result!![1] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" how :can I help:", (result!![2] as Nip30CustomEmoji.TextType).text) + } + + @Test() + fun parseJapanese() { + val tags = mapOf(":x30EDE:" to "http://x30EDE", ":\uD883\uDEDE:" to "http://\uD883\uDEDE") + val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(3, result!!.size) + + assertEquals("\uD883\uDEDE\uD883\uDEDE麺の", (result!![0] as Nip30CustomEmoji.TextType).text) + assertEquals("http://x30EDE", (result!![1] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("。:\uD883\uDEDE:(Violation of NIP-30)", (result!![2] as Nip30CustomEmoji.TextType).text) + } + + @Test() + fun parseJapanese2() { + val tags = + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ) + val input = + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, ImmutableListOfLists(tags)) + + assertEquals(9, result!!.size) + + var i = 0 + assertEquals("\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/emoji/_ri.png", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200B\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/emoji/_ri.png", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200Bはベイクドモチョチョ\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200Bを食べました\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200B\n#ioメシヨソイゲーム\nhttps://misskey.io/play/9g3qza4jow", (result!![i++] as Nip30CustomEmoji.TextType).text) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TimeUtilsTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TimeUtilsTest.kt new file mode 100644 index 000000000..34528d91b --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TimeUtilsTest.kt @@ -0,0 +1,65 @@ +/** + * 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.quartz.encoders + +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.containsAny +import junit.framework.TestCase +import org.junit.Test + +class TimeUtilsTest { + private val test = + """Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. + +The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. +""" + .intern() + + val atTheMiddle = DualCase("Lorem Ipsum".lowercase(), "Lorem Ipsum".uppercase()) + val atTheBeginning = DualCase("contrAry".lowercase(), "contrAry".uppercase()) + + val atTheEndCase = DualCase("h. rackham".lowercase(), "h. rackham".uppercase()) + + val lastCase = + listOf( + DualCase("my mom".lowercase(), "my mom".uppercase()), + DualCase("my dad".lowercase(), "my dad".uppercase()), + DualCase("h. rackham".lowercase(), "h. rackham".uppercase()), + ) + + @Test + fun middleCaseOurs() { + val list = listOf(atTheMiddle) + TestCase.assertTrue(test.containsAny(list)) + } + + @Test + fun atTheBeginningOurs() { + val list = listOf(atTheBeginning) + TestCase.assertTrue(test.containsAny(list)) + } + + @Test + fun atTheEndOurs() { + val list = listOf(atTheEndCase) + TestCase.assertTrue(test.containsAny(list)) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt index 82e0f6011..db2a99c03 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in diff --git a/settings.gradle b/settings.gradle index d535b492f..6ac181c95 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,3 +23,4 @@ rootProject.name = "Amethyst" include ':app' include ':benchmark' include ':quartz' +include ':commons' diff --git a/spotless/copyright.kt b/spotless/copyright.kt index 01566bc35..f46bcc74d 100644 --- a/spotless/copyright.kt +++ b/spotless/copyright.kt @@ -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 * this software and associated documentation files (the "Software"), to deal in