diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a120a8a5e..347c7b548 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,16 @@ jobs: - name: Test + Build Desktop (gradle) run: ./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }} --no-daemon + # jpackage pins libicu to the build host's version (libicu74 on + # ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu + # releases can install the uploaded artifact. + - name: Relax libicu dependency in .deb + if: matrix.desktop-task == 'packageDeb' + run: | + set -euo pipefail + chmod +x scripts/relax-deb-libicu.sh + scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main/deb/*.deb + - name: Upload Desktop Distribution uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0ac241bfb..e5ce7edcd 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -103,6 +103,15 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} + # jpackage pins libicu to the build host's version (libicu74 on + # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. + - name: Relax libicu dependency in .deb + if: matrix.family == 'linux' + run: | + set -euo pipefail + chmod +x scripts/relax-deb-libicu.sh + scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + - name: Build portable archives (windows + linux-portable) if: matrix.family == 'windows' || matrix.family == 'linux-portable' run: | @@ -248,6 +257,15 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + # jpackage pins libicu to the build host's version (libicu74 on + # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. + - name: Relax libicu dependency in .deb + if: matrix.family == 'linux' + run: | + set -euo pipefail + chmod +x scripts/relax-deb-libicu.sh + scripts/relax-deb-libicu.sh cli/build/jpackage/*.deb + - name: Collect + rename assets run: | set -euo pipefail diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 98d4127a9..fb4ddb6d8 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -346,6 +346,11 @@ dependencies { playImplementation libs.google.mlkit.genai.prompt playImplementation libs.google.mlkit.genai.rewriting + // On-device alt-text suggestions: genai image description (preferred, descriptive sentences) + // with image-labeling as a keyword-join fallback for devices without AICore. + playImplementation libs.google.mlkit.genai.image.description + playImplementation libs.google.mlkit.image.labeling + // PushNotifications playImplementation platform(libs.firebase.bom) playImplementation libs.firebase.messaging diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt new file mode 100644 index 000000000..227812f32 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +@file:OptIn(ExperimentalFoundationApi::class) + +package com.vitorpamplona.amethyst + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives [MentionPreservingInputTransformation] against a real [TextFieldState] + * with simulated IME edits. The npub literal has no metadata loaded — these + * tests only exercise the input-side guard, which keys off the underlying bech32 + * text rather than any display-name resolution. + */ +@RunWith(AndroidJUnit4::class) +class MentionPreservingInputTransformationTest { + /** 64 characters: leading `@` + bech32 (`npub1` + 58 chars). */ + private val npub = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + + /** + * Apply [stage] inside an edit session, then run the InputTransformation + * exactly as the framework would, and return the committed text. + */ + private fun TextFieldState.applyChange(stage: TextFieldBuffer.() -> Unit): String { + edit { + stage() + with(MentionPreservingInputTransformation) { + transformInput() + } + } + return text.toString() + } + + @Test + fun mentionFreeText_passesThrough() { + val state = TextFieldState("hello world") + val result = state.applyChange { replace(0, 5, "HELLO") } + assertEquals("HELLO world", result) + } + + @Test + fun pureDeleteFullyCoveringMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteInsideMention_collapsesAtomically() { + val state = TextFieldState(npub) + // delete a chunk near the end of the bech32 + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteAtMentionStart_collapsesAtomically() { + val state = TextFieldState(npub) + // delete the leading "@npub" prefix only + val result = state.applyChange { replace(0, 5, "") } + assertEquals("", result) + } + + @Test + fun scopeExactReplaceWithNonEmpty_collapsesAtomically() { + // SwiftKey case: IME fully covers the mention range and writes a + // shortened replacement (e.g. one of the multi-word display tokens). + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "@John") } + assertEquals("", result) + } + + @Test + fun scopeBroaderReplace_passesThrough() { + // Select-all + type: change covers the mention plus surrounding text. + // Treated as a deliberate broader edit; the typed character is preserved. + val state = TextFieldState("hi $npub world") + val result = state.applyChange { replace(0, length, "x") } + assertEquals("x", result) + } + + @Test + fun appendAfterMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { append(" hello") } + assertEquals("$npub hello", result) + } + + @Test + fun mentionWithTrailingSpace_collapseConsumesSpace() { + val state = TextFieldState("$npub hello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun mentionWithTrailingNewline_collapseConsumesNewline() { + val state = TextFieldState("$npub\nhello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun multipleMentions_partialOnSecond_onlySecondCollapsed() { + val text = "$npub and $npub" + val state = TextFieldState(text) + // partial delete inside the second mention only + val result = state.applyChange { replace(text.length - 4, text.length, "") } + assertEquals("$npub and ", result) + } + + @Test + fun mentionFreeChange_skipsRegexEntirely() { + // No "npub1" or "nprofile1" substring in the original text — the + // cheap-gate path should exit before any regex work. + val state = TextFieldState("hello world this is plain text") + val result = state.applyChange { replace(5, 11, "") } + assertEquals("hello this is plain text", result) + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 4a4a3ec15..b36312e75 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.TransformedText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.LocalCache @@ -49,28 +48,6 @@ class UrlUserTagTransformationTest { assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) } - fun debugCursor( - original: String, - transformedText: TransformedText, - offset: Int, - ): String { - val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset) - val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length) - val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length) - return "$originalWithCursor $transformedWithCursor" - } - - fun debugCursorReverse( - original: String, - transformedText: TransformedText, - offsetTransformed: Int, - ): String { - val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed) - val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length) - val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length) - return "$originalWithCursor $transformedWithCursor" - } - @Test fun testKeepTransformedIndexFullyInsideTransformedText() { val user = @@ -103,91 +80,21 @@ class UrlUserTagTransformationTest { val expected = "@Vitor Pamplona" assertEquals(expected, transformedText.text.text) - assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0)) - assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1)) - assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2)) - assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3)) - assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4)) - assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5)) - assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6)) - assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7)) - assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8)) - assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9)) - assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10)) - assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11)) - assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12)) - assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13)) - assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14)) - assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15)) - assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16)) - assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17)) - assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18)) - assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19)) - assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20)) - assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21)) - assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22)) - assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23)) - assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24)) - assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25)) - assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26)) - assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64)) - - assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0)) - assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1)) - assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2)) - assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3)) - assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4)) - assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5)) - assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15)) - + // The mention is treated as an atomic wedge: any cursor strictly inside the + // underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona" + // (and vice versa). This prevents an IME from placing the cursor in the middle + // of the bech32 and corrupting it on backspace. assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) + for (i in 1..63) { + assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i)) + } assertEquals(15, transformedText.offsetMapping.originalToTransformed(64)) + + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + for (i in 1..14) { + assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i)) + } + assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15)) } @Test @@ -219,23 +126,30 @@ class UrlUserTagTransformationTest { assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + // Outside the wedge: identity mapping. assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H - assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ - assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n - assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p - assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u - assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary) - assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) + // Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23). + assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + + // End-of-wedge boundary maps to end of displayed mention. assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + // Outside the wedge in displayed: identity. assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) - assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) - assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary) + // Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72). + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) + + // End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas. assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) } @@ -272,26 +186,40 @@ class UrlUserTagTransformationTest { assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) - assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + // Strictly inside the first underlying npub [8, 72): snap to end of first + // displayed "@Vitor Pamplona" (offset 23). + assertEquals(23, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) - assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 - assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z + // Boundary at end of first wedge: end of first displayed mention. assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before - assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ - assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge) - assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + // Strictly inside the second underlying npub [77, 141): snap to end of second + // displayed "@Vitor Pamplona" (offset 43). + assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(43, transformedText.offsetMapping.originalToTransformed(140)) + + // Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of + // first underlying npub (offset 72). + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display) assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before - assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge) + + // Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of + // second underlying npub (offset 141). + assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29)) + assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42)) } } diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt new file mode 100644 index 000000000..f9bd93d74 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.ai + +import android.content.Context +import android.net.Uri + +@Suppress("UNUSED_PARAMETER") +class MLKitImageLabelService( + context: Context, +) { + suspend fun labelImage(uri: Uri): List> = emptyList() + + suspend fun suggestAltText(uri: Uri): String? = null + + fun close() {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 275ddffe0..5ea95f359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -525,10 +525,16 @@ class AppModules( } } - // initializes diskcache on an IO thread. + // Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite + // index over StandaloneDatabaseProvider and walks every cached span on disk — up to a + // few hundred ms on a populated 4 GB cache — so leaving it for the first session's + // onGetSession would do that work on the main thread. The short delay keeps the IO + // dispatcher free for the urgent first-paint work above (account load, image loader, + // ui state, robohash) while still landing the warmup well before a typical user can + // scroll to and tap a video. The previous 10 s delay was long enough that a fast user + // (or a deep link) could lose the lazy { } race and trigger main-thread init. applicationIOScope.launch { - // Prepares video cache later - delay(10_000) + delay(1_500) videoCache } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index a9f386356..9265d889d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -78,7 +78,6 @@ class NotificationRelayService : Service() { private const val NOTIFICATION_ID = 9832 private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" - private const val ACTION_STOP = "com.vitorpamplona.amethyst.STOP_NOTIFICATION_SERVICE" const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" @@ -129,21 +128,11 @@ class NotificationRelayService : Service() { flags: Int, startId: Int, ): Int { - when (intent?.action) { - ACTION_STOP -> { - Log.d(TAG, "Stopping service") - stopSelf() - return START_NOT_STICKY - } - - else -> { - Log.d(TAG, "Starting service") - // Safety: also call startForeground from onStartCommand in case - // onCreate didn't complete before onStartCommand fired (ntfy #1520) - initializeForeground() - startRelayConnection() - } - } + Log.d(TAG, "Starting service") + // Safety: also call startForeground from onStartCommand in case + // onCreate didn't complete before onStartCommand fired (ntfy #1520) + initializeForeground() + startRelayConnection() return START_STICKY } @@ -288,25 +277,12 @@ class NotificationRelayService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - val stopIntent = - Intent(this, NotificationRelayService::class.java).apply { - action = ACTION_STOP - } - val stopPendingIntent = - PendingIntent.getService( - this, - 1, - stopIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) .setContentText(contentText) .setSmallIcon(R.drawable.amethyst) .setContentIntent(pendingIntent) - .addAction(0, getString(R.string.always_on_notif_stop), stopPendingIntent) .setOngoing(true) .setSilent(true) .setPriority(NotificationCompat.PRIORITY_LOW) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index b561a4538..869dc653d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.Player import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient @@ -49,25 +50,43 @@ fun GetVideoController( ).onEach { state -> Log.d("PlaybackService") { "Controller instance: ${state.controller}" } - if (BackgroundMedia.isPlaying()) { - // There is a video playing, start this one on mute. - state.controller.volume = 0f - Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying") - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - state.controller.volume = if (muted) 0f else 1f - Log.d("PlaybackService") { "OnEach $muted" } + // The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda + // sets it to 0f when the player is acquired, so the controller arrives at 0f. + // Read first and only push an IPC if the value actually needs to change — + // with several feed videos preloading at once each volume= write was a + // round-trip to the service for nothing. + val targetVolume = + when { + BackgroundMedia.isPlaying() -> 0f + muted -> 0f + else -> 1f + } + if (state.controller.volume != targetVolume) { + state.controller.volume = targetVolume + Log.d("PlaybackService") { "OnEach volume=$targetVolume" } } if (play) { state.controller.playWhenReady = true } - state.controller.setMediaItem(mediaItem.item) - state.controller.prepare() + // Warm-pool fast path: when the underlying ExoPlayer was retained paused-with- + // buffer for this exact MediaItem, the MediaController's local mirror already + // shows the matching mediaId. Calling setMediaItem in that case would reset the + // player and discard the buffer — exactly what the warm pool exists to avoid. + // We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted + // to cold and resurfaced, or hit an error before we attached). + val targetMediaId = mediaItem.item.mediaId + val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId + if (needsLoad) { + state.controller.setMediaItem(mediaItem.item) + state.controller.prepare() + } else if (state.controller.playbackState == Player.STATE_IDLE) { + Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" } + state.controller.prepare() + } } - }.collectAsStateWithLifecycle(null) + }.collectAsState(null) controllerState?.let { inner(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt index 9bcabaf0d..0189aa04a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt @@ -29,7 +29,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import coil3.asDrawable +import coil3.imageLoader +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException @Composable fun LoadThumbAndThenVideoView( @@ -45,56 +52,47 @@ fun LoadThumbAndThenVideoView( accountViewModel: AccountViewModel, onDialog: (() -> Unit)? = null, ) { - var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } + var loadingFinished by remember(thumbUri) { mutableStateOf>(Pair(false, null)) } val context = LocalContext.current - LaunchedEffect(Unit) { - accountViewModel.loadThumb( - context, - thumbUri, - onReady = { - loadingFinished = - if (it != null) { - Pair(true, it) - } else { - Pair(true, null) + // Run the Coil fetch in this LaunchedEffect's scope (was previously launched into the + // AccountViewModel's viewModelScope, which meant a scroll-away wouldn't cancel the in-flight + // image request — wasted bandwidth, plus the late callback wrote into a state that no + // longer mattered). Keying on thumbUri also makes the effect re-fire when a recycled slot + // gets a new audio track with a new cover instead of stalling on the stale Pair(true, ...). + LaunchedEffect(thumbUri) { + loadingFinished = + try { + val request = ImageRequest.Builder(context).data(thumbUri).build() + val drawable = + withContext(Dispatchers.IO) { + context.imageLoader + .execute(request) + .image + ?.asDrawable(context.resources) } - }, - onError = { loadingFinished = Pair(true, null) }, - ) + Pair(true, drawable) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("VideoView", "Fail to load cover $thumbUri", e) + Pair(true, null) + } } if (loadingFinished.first) { - if (loadingFinished.second != null) { - VideoView( - videoUri = videoUri, - mimeType = mimeType, - title = title, - thumb = VideoThumb(loadingFinished.second), - roundedCorner = roundedCorner, - contentScale = contentScale, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - isLiveStream = isLiveStream, - accountViewModel = accountViewModel, - onDialog = onDialog, - ) - } else { - VideoView( - videoUri = videoUri, - mimeType = mimeType, - title = title, - thumb = null, - roundedCorner = roundedCorner, - contentScale = contentScale, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - isLiveStream = isLiveStream, - accountViewModel = accountViewModel, - onDialog = onDialog, - ) - } + VideoView( + videoUri = videoUri, + mimeType = mimeType, + title = title, + thumb = loadingFinished.second?.let { VideoThumb(it) }, + roundedCorner = roundedCorner, + contentScale = contentScale, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + isLiveStream = isLiveStream, + accountViewModel = accountViewModel, + onDialog = onDialog, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt index 4a353a6fe..701ff382c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt @@ -31,14 +31,13 @@ import kotlin.uuid.Uuid class MediaControllerState( // each composable has an ID. val id: String = Uuid.random().toString(), - // This is filled after the controller returns from this class - var controller: Player, + val controller: Player, // visibility onscreen val visibility: VisibilityData = VisibilityData(), ) { fun isPlaying() = controller.isPlaying - fun currrentMedia() = controller.currentMediaItem?.mediaId + fun currentMedia() = controller.currentMediaItem?.mediaId fun toggleMute() { controller.volume = if (controller.volume == 0f) 1f else 0f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 723899fd8..c86b117e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.IntSize import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame @@ -90,19 +89,22 @@ fun RenderVideoPlayer( hasBlurhash: Boolean = false, accountViewModel: AccountViewModel, ) { - val containerSize = remember { mutableStateOf(IntSize.Zero) } - val isLive = isLiveStreaming(mediaItem.src.videoUri) + // Hold the container size in a non-state holder so layout passes don't trigger an + // unnecessary recomposition of the whole player tree just to update a value that is only + // ever read inside the onDoubleTap callback below. + val containerWidth = remember { intArrayOf(0) } + val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) } Box( modifier = borderModifier - .onSizeChanged { containerSize.value = it } + .onSizeChanged { containerWidth[0] = it.width } .pointerInput(isLive, controllerState) { detectTapGestures( onTap = { controllerVisible.value = !controllerVisible.value }, onDoubleTap = { offset -> if (!isLive) { - val isLeftSide = offset.x < containerSize.value.width / 2 + val isLeftSide = offset.x < containerWidth[0] / 2 if (isLeftSide) { controllerState.controller.seekBackward() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 885ecaaba..d7263930d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -105,15 +105,32 @@ fun VideoView( thumbhash: String? = null, ) { val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback() - val automaticallyStartPlayback = remember { mutableStateOf(initialAutoStart) } + // Reset the manual-show toggle when the video URI changes so a recycled feed slot + // doesn't inherit "tapped to show" state from a prior video. + val automaticallyStartPlayback = remember(videoUri) { mutableStateOf(initialAutoStart) } // Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded. // If the user manually tapped the download button, they want it to play. val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value) - if (blurhash == null && thumbhash == null) { - val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) + // Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta + // dim tag so the next time this video appears (PiP, dialog, list re-enter) the cache hits + // without waiting for ExoPlayer's onVideoSizeChanged. Keys are primitive width/height so + // a freshly parsed DimensionTag instance for the same event doesn't re-run this lambda — + // DimensionTag uses reference equality, not structural. + val dimW = dimensions?.width + val dimH = dimensions?.height + val ratio = + remember(videoUri, dimW, dimH) { + if (dimW != null && dimH != null && dimW > 0 && dimH > 0) { + MediaAspectRatioCache.add(videoUri, dimW, dimH) + dimW.toFloat() / dimH.toFloat() + } else { + MediaAspectRatioCache.get(videoUri) + } + } + if (blurhash == null && thumbhash == null) { val modifier = if (ratio != null && automaticallyStartPlayback.value) { Modifier.aspectRatio(ratio) @@ -149,8 +166,6 @@ fun VideoView( } } } else { - val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) - val modifier = if (ratio != null) { Modifier.aspectRatio(ratio) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 8923783f0..49e5651a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -59,6 +59,11 @@ fun VideoViewInner( // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value } + // The proxy port is decided once per video URI; recomputing on every recomposition does + // pointless work (the result is anyway dropped because GetMediaItem.remember is keyed on the + // URI alone, so the cached MediaItemData is locked in on the first frame). + val proxyPort = remember(videoUri) { accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri) } + GetMediaItem( videoUri = videoUri, title = title, @@ -67,7 +72,7 @@ fun VideoViewInner( callbackUri = nostrUriCallback, mimeType = mimeType, aspectRatio = aspectRatio, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri), + proxyPort = proxyPort, keepPlaying = true, waveformData = waveform, isLiveStream = isLiveStream, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt index 938aa8ef9..be5d946f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt @@ -38,24 +38,33 @@ import androidx.compose.ui.unit.dp private val FadeIn = fadeIn() private val FadeOut = fadeOut() -private val TopGradientColors = - listOf( - Color.Black.copy(alpha = 0.6f), - Color.Black.copy(alpha = 0.3f), - Color.Transparent, +// Both gradient brushes are static; pre-build them once at class init so we don't allocate a +// new Brush on every recomposition while the controllers are visible (which is most of the +// time during playback / interaction). +private val TopGradientBrush = + Brush.verticalGradient( + colors = + listOf( + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.3f), + Color.Transparent, + ), ) -private val BottomGradientColors = - listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.4f), - Color.Black.copy(alpha = 0.7f), +private val BottomGradientBrush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.7f), + ), ) @Composable private fun GradientOverlay( controllerVisible: State, - colors: List, + brush: Brush, height: Dp, modifier: Modifier = Modifier, ) { @@ -70,7 +79,7 @@ private fun GradientOverlay( Modifier .fillMaxWidth() .height(height) - .background(brush = Brush.verticalGradient(colors = colors)), + .background(brush = brush), ) } } @@ -80,11 +89,11 @@ fun TopGradientOverlay( controllerVisible: State, modifier: Modifier = Modifier, height: Dp = 80.dp, -) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier) +) = GradientOverlay(controllerVisible, TopGradientBrush, height, modifier) @Composable fun BottomGradientOverlay( controllerVisible: State, modifier: Modifier = Modifier, height: Dp = 120.dp, -) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier) +) = GradientOverlay(controllerVisible, BottomGradientBrush, height, modifier) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt index 5c44ba3c8..787c7e30a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt @@ -47,9 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Preview @Composable @@ -79,11 +77,12 @@ fun MuteButton( ) } + // LaunchedEffect already runs on Main, and delay() suspends without holding a thread, so + // the previous launch(Dispatchers.IO) was just unnecessary dispatcher hopping for a state + // mutation that's also fine on Main. LaunchedEffect(key1 = controllerVisible) { - launch(Dispatchers.IO) { - delay(2000) - holdOn.value = false - } + delay(2000) + holdOn.value = false } val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index b8a115a5c..1bf719a92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -50,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private val FadeIn = fadeIn() private val FadeOut = fadeOut() @@ -60,7 +62,7 @@ fun OverflowMenuButtonPreview() { ThemeComparisonColumn { Box(Modifier.background(BitcoinOrange)) { OverflowMenuButton( - actions = listOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture), + actions = persistentListOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture), startingMuteState = false, onFullscreenClick = {}, onMuteClick = {}, @@ -76,7 +78,7 @@ fun OverflowMenuButtonPreview() { @Composable fun AnimatedOverflowMenuButton( controllerVisible: State, - actions: List, + actions: ImmutableList, startingMuteState: Boolean, onFullscreenClick: (() -> Unit)?, onMuteClick: () -> Unit, @@ -107,7 +109,7 @@ fun AnimatedOverflowMenuButton( @Composable fun OverflowMenuButton( - actions: List, + actions: ImmutableList, startingMuteState: Boolean, onFullscreenClick: (() -> Unit)?, onMuteClick: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 5c4d4250e..c694c688c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.collections.immutable.toImmutableList @Preview @Composable @@ -105,7 +106,7 @@ fun RenderTopButtons( accountViewModel: AccountViewModel, ) { val context = LocalContext.current - val isLive = isLiveStreaming(mediaData.videoUri) + val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) } val pipSupported = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) @@ -212,17 +213,22 @@ fun RenderTopButtons( } val canFullscreen = onZoomClick != null + // ImmutableList so Compose can treat the action lists as stable parameters when they're + // passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the + // overflow tree to recompose whenever any unrelated parent state ticks. val topBarActions = remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) { buttonItems .filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) } .map { it.action } + .toImmutableList() } val overflowActions = remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) { buttonItems .filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) } .map { it.action } + .toImmutableList() } Row(modifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 59a425981..aa9c54081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -58,7 +58,7 @@ fun RenderPipVideo( val modifier = remember { val ratio = - controller.currrentMedia()?.let { + controller.currentMedia()?.let { MediaAspectRatioCache.get(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 4ceb4497b..eaa31e31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -24,6 +24,7 @@ import android.content.Context import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache @@ -42,10 +43,35 @@ class ExoPlayerBuilder( .Builder(context) .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) + setLoadControl(feedTunedLoadControl()) }.build() .apply { addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } + + companion object { + // Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video + // in the feed is prepared eagerly so it's ready when the user scrolls to it; with the + // default settings 5 simultaneous preloads would fight for ~250s of buffer between them + // and chew through ~30+ MB per HD player. Feed playback is optimized for "the active + // video plays smoothly while a few neighbours stay warm," so we cap the buffer at ~15s + // and let playback kick in as soon as ~750 ms is buffered. Fullscreen still gets a + // healthy buffer because seeks-within-15s are virtually instant from disk cache. + private fun feedTunedLoadControl() = + DefaultLoadControl + .Builder() + .setBufferDurationsMs( + // minBufferMs = + 10_000, + // maxBufferMs = + 15_000, + // bufferForPlaybackMs = + 750, + // bufferForPlaybackAfterRebufferMs = + 2_000, + ).setPrioritizeTimeOverSizeThresholds(true) + .build() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 4a32d8066..dbdc7f101 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -34,16 +34,43 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean @OptIn(UnstableApi::class) class ExoPlayerPool( val builder: ExoPlayerBuilder, private val poolSize: Int, + // Requested ceiling on paused-with-buffer players retained across releases. Each retained + // player keeps its decoder and LoadControl buffer alive, which costs both memory and a + // MediaCodec instance, so warm slots count against the same [poolSize] codec budget as + // cold players (see [warmSlotsCap]). Default 3 keeps the most recent few feed videos hot + // for scroll-back without monopolizing the device's decoder pool. + requestedWarmSlots: Int = DEFAULT_WARM_SLOTS, ) { - private val playerPool = ConcurrentLinkedQueue() + // Cap warm slots at poolSize-1 so there's always at least one slot available for a cold + // (cleared) player; otherwise a feed full of unique URIs would starve the cold pool and + // every new URI would force a fresh ExoPlayer build. + private val warmSlotsCap = requestedWarmSlots.coerceAtMost((poolSize - 1).coerceAtLeast(0)) + + // Idle players that have been stop()'d and clearMediaItems()'d — ready to be re-prepared + // with any URI. Maintained as a FIFO so the oldest cleared instance is reused first. + private val coldPool = ConcurrentLinkedQueue() private val poolStartingSize = 3 + // Most-recent paused players, indexed by the mediaId of the MediaItem they still hold. + // ArrayDeque is used as an LRU: head = oldest, tail = newest. Access is guarded by + // [warmPoolLock] (a plain monitor, since both acquire and release callers run on the + // service's main thread but we don't want to require the suspending [mutex] in acquire). + private data class WarmPlayer( + val mediaId: String, + val player: ExoPlayer, + ) + + private val warmPool = ArrayDeque(warmSlotsCap.coerceAtLeast(1)) + private val warmPoolLock = Any() + // Exists to avoid exceptions stopping the coroutine val exceptionHandler = CoroutineExceptionHandler { _, throwable -> @@ -54,21 +81,63 @@ class ExoPlayerPool( private val mutex = Mutex() + // Guards against firing the warmup more than once if create() is called repeatedly + // (e.g. on reconfiguration or when both pools share a startup hook). + private val warmupStarted = AtomicBoolean(false) + + /** + * Pre-warms the pool with [poolStartingSize] ExoPlayer instances on the main looper, yielding + * between each build so the warmup is spread across frames instead of stalling the UI in one + * burst. ExoPlayer must be constructed on the same thread that will operate it (the main + * thread for this pool), so we cannot fan out across IO threads here. Idempotent — additional + * calls are no-ops. + */ fun create(context: Context) { - while (playerPool.size < poolStartingSize) { - playerPool.offer(builder.build(context)) + if (!warmupStarted.compareAndSet(false, true)) return + scope.launch { + while (coldPool.size < poolStartingSize) { + coldPool.offer(builder.build(context)) + // Hand the frame back so an in-flight onGetSession / acquirePlayer / layout + // pass isn't blocked behind the next build. + yield() + } } } - fun acquirePlayer(context: Context): ExoPlayer { - if (playerPool.isEmpty()) { - // If the pool is empty, create a new player (or handle it differently) - return builder.build(context) + /** + * Acquire a player. When [preferredMediaId] matches a warm entry, returns that player intact + * — it still holds its MediaItem and any populated LoadControl buffer, so the caller can + * skip [androidx.media3.common.Player.setMediaItem] / [androidx.media3.common.Player.prepare] + * and resume immediately. Falls back to a cold (cleared) player or a freshly built one. + */ + fun acquirePlayer( + context: Context, + preferredMediaId: String? = null, + ): ExoPlayer { + if (preferredMediaId != null) { + val warm = takeWarm(preferredMediaId) + if (warm != null) { + Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } + return warm + } } - - return playerPool.poll() ?: builder.build(context) + return coldPool.poll() ?: builder.build(context) } + private fun takeWarm(mediaId: String): ExoPlayer? = + synchronized(warmPoolLock) { + // Iterate from the newest end so a duplicated URI returns the freshest player. + val it = warmPool.listIterator(warmPool.size) + while (it.hasPrevious()) { + val entry = it.previous() + if (entry.mediaId == mediaId) { + it.remove() + return@synchronized entry.player + } + } + null + } + fun releasePlayerAsync(player: ExoPlayer) { scope.launch { releasePlayer(player) @@ -77,27 +146,70 @@ class ExoPlayerPool( suspend fun releasePlayer(player: ExoPlayer) { mutex.withLock { - if (!player.isReleased) { + if (player.isReleased) return@withLock + + val mediaId = player.currentMediaItem?.mediaId + if (mediaId != null && warmSlotsCap > 0) { + // Warm path: keep the player paused but loaded so a quick scroll-back to the + // same video resumes from the existing buffer instead of re-fetching from disk + // cache and re-priming the decoder. player.pause() - player.stop() - player.clearVideoSurface() - player.clearMediaItems() - - // Clear any video quality overrides so the next video starts with Auto - player.trackSelectionParameters = - player.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(C.TRACK_TYPE_VIDEO) - .build() - - if (playerPool.size < poolSize) { - if (!playerPool.contains(player)) { - playerPool.add(player) - } - } else { - player.release() // Release if pool is full. + val evicted = pushWarm(mediaId, player) + if (evicted != null) { + Log.d("PlaybackService") { "ExoPlayerPool warm evict: ${evicted.mediaId}" } + demoteToCold(evicted.player) } + return@withLock } + + demoteToCold(player) + } + } + + private fun pushWarm( + mediaId: String, + player: ExoPlayer, + ): WarmPlayer? = + synchronized(warmPoolLock) { + // If the same URI is already warm (rare — duplicate VideoView in another scroller), + // drop the older entry so it can be demoted; the freshest copy wins. + val duplicate = warmPool.indexOfFirst { it.mediaId == mediaId } + val displaced = + if (duplicate >= 0) { + warmPool.removeAt(duplicate) + } else if (warmPool.size >= warmSlotsCap) { + warmPool.removeFirst() + } else { + null + } + warmPool.addLast(WarmPlayer(mediaId, player)) + displaced + } + + private fun demoteToCold(player: ExoPlayer) { + if (player.isReleased) return + player.pause() + player.stop() + player.clearVideoSurface() + player.clearMediaItems() + + // Clear any video quality overrides so the next video starts with Auto + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() + + // Total idle (cold + warm) must respect the device-derived poolSize cap so we don't + // exceed the MediaCodec instance budget. Warm slots get first dibs; cold gets the rest. + val warmSize = synchronized(warmPoolLock) { warmPool.size } + val coldCap = (poolSize - warmSize).coerceAtLeast(0) + if (coldPool.size < coldCap) { + if (!coldPool.contains(player)) { + coldPool.add(player) + } + } else { + player.release() // Release if pool is full. } } @@ -105,11 +217,22 @@ class ExoPlayerPool( scope .launch { mutex.withLock { - playerPool.forEach { it.release() } - playerPool.clear() + val warmSnapshot = + synchronized(warmPoolLock) { + val copy = warmPool.toList() + warmPool.clear() + copy + } + warmSnapshot.forEach { it.player.release() } + coldPool.forEach { it.release() } + coldPool.clear() } }.invokeOnCompletion { scope.cancel() } } + + companion object { + private const val DEFAULT_WARM_SLOTS = 3 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 501354b11..d05267f3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -18,6 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@file:OptIn(UnstableApi::class) + package com.vitorpamplona.amethyst.service.playback.playerPool import android.app.PendingIntent @@ -37,13 +39,14 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong class SessionListener( val session: MediaSession, @@ -72,7 +75,25 @@ class MediaSessionPool( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler) val globalCallback = MediaSessionCallback(this, appContext) - var lastCleanup = TimeUtils.now() + + // Last cleanup timestamp in nanos, guarded by CAS so concurrent releaseSession() calls + // can't all win the time check and each launch a redundant scope.launch sweep. + private val lastCleanupNs = AtomicLong(System.nanoTime()) + + // The bitmap loader is stateless w.r.t. the session; a fresh allocation per session was + // pure noise. ExoPlayer's DEFAULT_EXECUTOR_SERVICE is a process-wide singleton, the + // dataSourceFactory is owned by the pool, and the appContext is already retained. + // The init is in a separate function so the @OptIn lands on a real declaration — + // applying it to a `by lazy` property doesn't propagate into the lambda body. + private val sharedBitmapLoader by lazy { buildSharedBitmapLoader() } + + @OptIn(UnstableApi::class) + private fun buildSharedBitmapLoader(): DataSourceBitmapLoader = + DataSourceBitmapLoader + .Builder(appContext) + .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) + .setDataSourceFactory(dataSourceFactory) + .build() // protects from LruCache killing playing sessions private val playingMap = mutableMapOf() @@ -102,18 +123,16 @@ class MediaSessionPool( id: String, keepPlaying: Boolean, context: Context, + // Best-effort affinity hint: when the pool still has a paused player carrying this + // exact mediaId (matches MediaItem.mediaId, which is the videoUri), the warm player + // is reused so the populated buffer survives. Null falls back to a cold acquire. + preferredMediaId: String?, ): MediaSession { val mediaSession = MediaSession - .Builder(context, exoPlayerPool.acquirePlayer(context)) + .Builder(context, exoPlayerPool.acquirePlayer(context, preferredMediaId)) .apply { - setBitmapLoader( - DataSourceBitmapLoader - .Builder(context) - .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) - .setDataSourceFactory(dataSourceFactory) - .build(), - ) + setBitmapLoader(sharedBitmapLoader) setId(id) setCallback(globalCallback) }.build() @@ -142,16 +161,17 @@ class MediaSessionPool( } fun cleanupUnused() { - if (lastCleanup < TimeUtils.oneMinuteAgo()) { - lastCleanup = TimeUtils.now() - scope.launch { - val snap = cache.snapshot() - snap.values.forEach { - if (it.session.connectedControllers.isEmpty()) { - releaseSession(it.session) - } + val now = System.nanoTime() + val previous = lastCleanupNs.get() + if (now - previous < CLEANUP_INTERVAL_NS) return + // CAS so only one caller actually launches the sweep when many releases fire at once. + if (!lastCleanupNs.compareAndSet(previous, now)) return + scope.launch { + val snap = cache.snapshot() + snap.values.forEach { + if (it.session.connectedControllers.isEmpty()) { + releaseSession(it.session) } - lastCleanup = TimeUtils.now() } } } @@ -175,13 +195,14 @@ class MediaSessionPool( id: String, keepPlaying: Boolean, context: Context, + preferredMediaId: String? = null, ): MediaSession { val existingSession = playingMap.get(id) ?: cache.get(id) if (existingSession != null) { return existingSession.session } - return newSession(id, keepPlaying, context) + return newSession(id, keepPlaying, context, preferredMediaId) } fun playingContent() = playingMap.values @@ -234,4 +255,8 @@ class MediaSessionPool( } } } + + companion object { + private val CLEANUP_INTERVAL_NS = TimeUnit.MINUTES.toNanos(1) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt index 837f85971..4734d7a69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt @@ -66,7 +66,14 @@ class CurrentPlayPositionCacher( Player.STATE_READY -> { if (!isLiveStreaming) { cache.get(uri)?.let { lastPosition -> - if (abs(player.currentPosition - lastPosition) > 5 * 60) { + // Restore the saved position only if it's meaningfully far from + // the player's current position. Position values are in + // milliseconds, so the previous `5 * 60` constant was a 300 ms + // threshold — small enough to trigger a seek (and an extra buffer + // flush right at playback start) for almost any saved position. + // 5 s gives the user a perceptible "resumed where I left off" + // without forcing a re-seek for trivially short clips. + if (abs(player.currentPosition - lastPosition) > 5_000) { player.seekTo(lastPosition) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 9b259e69c..795f04157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -105,7 +105,15 @@ class PlaybackService : MediaSessionService() { val blossomServerResolver = Amethyst.instance.blossomResolver // creates new - return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it } + return newPool(videoCache, okHttpClient, blossomServerResolver) + .also { + poolNoProxy = it + // Kick off the player pool warmup as soon as we know this pool is being used. + // It runs async on the main looper, yielding between builds, so the very first + // session still acquires synchronously while subsequent ones can grab a warm + // ExoPlayer instead of paying the build cost on the main thread. + it.exoPlayerPool.create(applicationContext) + } } else { poolWithProxy?.let { return it } @@ -116,7 +124,11 @@ class PlaybackService : MediaSessionService() { val videoCache = Amethyst.instance.videoCache val blossomServerResolver = Amethyst.instance.blossomResolver - return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it } + return newPool(videoCache, okHttpClient, blossomServerResolver) + .also { + poolWithProxy = it + it.exoPlayerPool.create(applicationContext) + } } } @@ -161,23 +173,27 @@ class PlaybackService : MediaSessionService() { return } - playing.forEachIndexed { idx, it -> + playing.forEach { if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) { super.onUpdateNotification(it.session, startInForegroundRequired) return } } - playing.forEachIndexed { idx, it -> + playing.forEach { if (it.session.player.isPlaying && it.session.player.volume > 0) { super.onUpdateNotification(it.session, startInForegroundRequired) return } } - playing.forEachIndexed { idx, it -> + // Falls through to the first muted-but-playing session. Earlier this loop missed + // its return and called super.onUpdateNotification once per playing session, + // hammering the notification system whenever multiple feed videos were preloading. + playing.forEach { if (it.session.player.isPlaying) { super.onUpdateNotification(it.session, startInForegroundRequired) + return } } } @@ -188,7 +204,17 @@ class PlaybackService : MediaSessionService() { val id = controllerInfo.connectionHints.getString("id") ?: return null val proxyPort = controllerInfo.connectionHints.getInt("proxyPort") val keepPlaying = controllerInfo.connectionHints.getBoolean("keepPlaying", true) + // Optional warm-pool affinity hint: when the pool still has a paused ExoPlayer + // holding this exact URI, the new session reuses it so the buffer survives. + val preferredMediaId = controllerInfo.connectionHints.getString(HINT_VIDEO_URI) val manager = lazyPool(proxyPort) - return manager.getSession(id, keepPlaying, applicationContext) + return manager.getSession(id, keepPlaying, applicationContext, preferredMediaId) + } + + companion object { + const val HINT_ID = "id" + const val HINT_PROXY_PORT = "proxyPort" + const val HINT_KEEP_PLAYING = "keepPlaying" + const val HINT_VIDEO_URI = "videoUri" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 3bd87f509..d641cd8d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -36,7 +36,15 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid object PlaybackServiceClient { - val executorService: ExecutorService = Executors.newCachedThreadPool() + // Runs the MediaController.buildAsync() completion callbacks. The work per callback is + // trivial in the steady state (Future.get() on an already-completed future + a non-blocking + // trySend into this video's own callbackFlow channel), so the IPC bind itself dominates and + // happens on Media3's own threads regardless. We size the pool small enough to stay bounded + // under churn but parallel enough that one stuck listener (e.g. the defensive 5s get() + // timeout actually firing) can't stall the rest of the videos onscreen behind it. The + // original newCachedThreadPool was unbounded and could spin up a thread per concurrent + // video, each lingering for the 60s keep-alive afterwards. + val executorService: ExecutorService = Executors.newFixedThreadPool(4) fun shutdown() { executorService.shutdown() @@ -56,11 +64,15 @@ object PlaybackServiceClient { Bundle().apply { // link the id with the client's id to make sure it can return the // same session on background media. - putString("id", id) - putBoolean("keepPlaying", keepPlaying) + putString(PlaybackService.HINT_ID, id) + putBoolean(PlaybackService.HINT_KEEP_PLAYING, keepPlaying) proxyPort?.let { - putInt("proxyPort", it) + putInt(PlaybackService.HINT_PROXY_PORT, it) } + // Carry the URI so the service can ask the player pool for an existing warm + // (paused-with-buffer) ExoPlayer that already holds this MediaItem. Falls back + // gracefully — if no warm match exists, the pool returns a cold player. + putString(PlaybackService.HINT_VIDEO_URI, videoUri) } val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 75a315266..f95914cb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent @@ -75,6 +76,7 @@ val AccountInfoAndListsFromKeyKinds2 = TrustProviderListEvent.KIND, PaymentTargetsEvent.KIND, RelayFeedsListEvent.KIND, + InterestSetEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) @@ -104,7 +106,7 @@ fun filterAccountInfoAndListsFromKey( Filter( kinds = AccountInfoAndListsFromKeyKinds2, authors = listOf(pubkey), - limit = 20, + limit = 80, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index e0285ce5d..70ac43cff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent @@ -67,6 +68,7 @@ val BasicAccountInfoKinds2 = GeohashListEvent.KIND, TrustProviderListEvent.KIND, RelayFeedsListEvent.KIND, + InterestSetEvent.KIND, ) fun filterBasicAccountInfoFromKeys( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt new file mode 100644 index 000000000..608099835 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.actions + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer + +/** + * Matches a complete Nostr mention token: `@npub1…`, `nostr:npub1…`, + * `@nprofile1…`, `nostr:nprofile1…`. Shared with [UrlUserTagOutputTransformation] + * so the wedge it produces and the input-side guard below agree on what + * counts as a mention. + */ +internal val MENTION_REGEX = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") + +/** + * Keeps Nostr mentions atomic against IME edits that would only modify part of + * the underlying bech32 (notably Microsoft SwiftKey, which re-enters word-edit + * mode over a previously-committed display token and rewrites a single word of + * a multi-word `@DisplayName`, leaving an orphan tail of the npub that no + * longer matches the mention regex). + * + * Three change shapes are blocked and routed to atomic-collapse: + * - Partial overlap (the change's `originalRange` overlaps a mention but does + * not fully cover it). + * - Scope-exact replace (the change's `originalRange` matches the mention's + * range exactly and the replacement is non-empty — covers IMEs that + * fully-cover-replace a multi-word display token with one of its words). + * + * Anything else passes through: + * - Pure delete that fully covers a mention (the user removed the chip). + * - A change whose range covers more than just the mention (select-all + type, + * select-paragraph + paste, etc.) — treated as deliberate broader edit. + */ +@OptIn(ExperimentalFoundationApi::class) +object MentionPreservingInputTransformation : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val changeCount = changes.changeCount + if (changeCount == 0) return + + val original = originalText + // Cheap gate — most keystrokes happen in mention-free text. + if (!original.contains("npub1") && !original.contains("nprofile1")) return + + val touched = + MENTION_REGEX.findAll(original).firstOrNull { match -> + val mStart = match.range.first + val mEndExclusive = match.range.last + 1 + (0 until changeCount).any { i -> + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + val isScopeExact = origStart == mStart && origEnd == mEndExclusive + val isPureDelete = changes.getRange(i).length == 0 + overlaps && (!fullyCovers || (isScopeExact && !isPureDelete)) + } + } ?: return + + revertAllChanges() + val mEndExclusive = touched.range.last + 1 + val deleteEnd = + if (mEndExclusive < length && asCharSequence()[mEndExclusive].isWhitespace()) { + mEndExclusive + 1 + } else { + mEndExclusive + } + replace(touched.range.first, deleteEnd, "") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt index 9d5dff684..506a8d338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -35,11 +35,8 @@ class UrlUserTagOutputTransformation( override fun TextFieldBuffer.transformOutput() { val text = asCharSequence().toString() - // Find all user mentions using regex and replace in reverse order - // so that earlier indices remain valid after replacements. - // Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1... - val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") - val matches = mentionRegex.findAll(text).toList().reversed() + // Reverse so earlier indices remain valid after each replace. + val matches = MENTION_REGEX.findAll(text).toList().reversed() // Phase 1: Replace all mentions (reverse order keeps indices valid for replace). // Collect replacement info because addStyle must be called after all text mutations. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 2bd2817af..a56eb7052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -138,14 +138,18 @@ fun buildAnnotatedStringWithUrlHighlighting( val numberOffsetTranslator = object : OffsetMapping { + // Treat each substitution as an atomic wedge: any cursor position that falls + // strictly inside a substituted range snaps to the wedge's trailing edge. + // Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the + // cursor in the middle of an "@npub1..." mention, and a subsequent backspace + // deletes a char from inside the bech32, breaking the npub and "expanding" + // the collapsed mention. override fun originalToTransformed(offset: Int): Int { val inInsideRange = substitutions.firstOrNull { offset > it.original.start && offset < it.original.end } if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) - return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt() + return inInsideRange.modified.end } val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } @@ -162,9 +166,7 @@ fun buildAnnotatedStringWithUrlHighlighting( substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end } if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) - return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt() + return inInsideRange.original.end } val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index b23559684..ea26b2582 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -78,15 +78,11 @@ fun AudioWaveformReadOnly( amplitudes: List, onProgressChange: (Float) -> Unit, ) { - val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } - val spikeWidthState = - remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } - val spikePaddingState = - remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } - val spikeRadiusState = - remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } - val spikeTotalWidthState = - remember(spikeWidth, spikePadding) { spikeWidthState + spikePaddingState } + val progressState = progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) + val spikeWidthState = spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) + val spikePaddingState = spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) + val spikeRadiusState = spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) + val spikeTotalWidthState = spikeWidthState + spikePaddingState var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } var spikes by remember { mutableFloatStateOf(0F) } val spikesAmplitudes = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index 1b33389f0..b9b695a55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -68,6 +68,10 @@ fun GifVideoView( accountViewModel: AccountViewModel, thumbhash: String? = null, ) { + // Pure read path — DimensionTag.aspectRatio() is a one-line int division and + // MediaAspectRatioCache.get() is a synchronized LruCache lookup. Wrapping this in + // remember() to avoid the recompute would cost more (slot read + N equality checks) + // than the work it saves; that's why this stays as a plain expression. val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) val autoPlay = accountViewModel.settings.autoPlayVideos() val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt index d27408007..61036e103 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt @@ -22,10 +22,17 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable +/** + * The current translation state for a piece of content. + * + * `sourceLang` and `targetLang` are non-null only when an actual translation took place; + * a no-op (same language, undetected, blocklisted) keeps both null and `result` equal to the + * original content. The user-facing "show original" toggle is derived live from + * `AccountLanguagePreferences.preferenceBetween(...)` and is not stored here. + */ @Immutable data class TranslationConfig( - val result: String?, + val result: String, val sourceLang: String?, val targetLang: String?, - val showOriginal: Boolean, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 7e0255b09..aa0f69ef9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -334,8 +334,8 @@ private fun DialogContent( AnimatedVisibility( visible = controllerVisible.value, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = fadeIn(), + exit = fadeOut(), // Also fade with the grow animation so controls appear/disappear alongside it. modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 0f67b1f25..dcfd488d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -404,8 +404,8 @@ fun LocalImageView( AnimatedVisibility( visible = controllerVisible.value, modifier = Modifier.align(Alignment.TopEnd), - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = fadeIn(), + exit = fadeOut(), ) { Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) { HashVerificationSymbol(it) @@ -649,8 +649,8 @@ fun ShowHashAnimated( AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = fadeIn(), + exit = fadeOut(), ) { Box(modifier, contentAlignment = Alignment.TopEnd) { ShowHash(content) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 710259e15..4761b28b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -34,15 +34,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,7 +68,6 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote @@ -92,10 +90,6 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -104,32 +98,27 @@ fun FeedFilterSpinner( placeholderCode: TopFilter, explainer: String, options: ImmutableList, - onSelect: (Int) -> Unit, + onSelect: (FeedDefinition) -> Unit, modifier: Modifier = Modifier, accountViewModel: AccountViewModel, ) { var optionsShowing by remember { mutableStateOf(false) } val context = LocalContext.current - val selectAnOption = - stringRes( - id = R.string.select_an_option, - ) + val selectAnOption = stringRes(id = R.string.select_an_option) - var selected by + val selected = remember(placeholderCode, options) { - mutableStateOf( - options.firstOrNull { it.code.code == placeholderCode.code }, - ) - } - - val currentText by - remember(placeholderCode, options) { - derivedStateOf { - selected?.name?.name(context) ?: selectAnOption + // Match by both subclass and code string to avoid collisions between + // TopFilter variants that derive `code` from the same Address (e.g. + // PeopleList vs MuteList). + options.firstOrNull { + it.code::class == placeholderCode::class && it.code.code == placeholderCode.code } } + val currentText = selected?.name?.name(context) ?: selectAnOption + val accessibilityDescription = if (selected != null) { stringRes(R.string.feed_filter_selected, currentText) @@ -282,10 +271,9 @@ fun FeedFilterSpinner( title = explainer, options = options, onDismiss = { optionsShowing = false }, - onSelect = { - selected = options[it] + onSelect = { definition -> optionsShowing = false - onSelect(it) + onSelect(definition) }, ) { RenderOption(it.name, accountViewModel) @@ -298,6 +286,7 @@ fun RenderOption( option: Name, accountViewModel: AccountViewModel, ) { + val context = LocalContext.current when (option) { is GeoHashName -> { LoadCityName(option.geoHashTag) { @@ -305,74 +294,35 @@ fun RenderOption( } } - is HashtagName -> { - Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) - } - - is ResourceName -> { - Text( - text = stringRes(id = option.resourceId), - fontSize = Font14SP, - color = MaterialTheme.colorScheme.onSurface, - ) - } - + // Note-backed names: subscribe to the note so the displayed title updates as + // the corresponding event arrives from relays. The displayed string itself is + // produced by Name.name(), which already has the right precedence rules. is PeopleListName -> { val noteState by observeNote(option.note, accountViewModel) - - val noteEvent = noteState.note.event - val name = - when (noteEvent) { - is PeopleListEvent -> { - noteEvent.titleOrName() ?: option.note.dTag() - } - - is FollowListEvent -> { - noteEvent.title() ?: option.note.dTag() - } - - else -> { - option.note.dTag() - } - } - + val name = remember(noteState) { option.name(context) } Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is CommunityName -> { - val it by observeNote(option.note, accountViewModel) - - val addressable = it.note as? AddressableNote - val definition = addressable?.event as? CommunityDefinitionEvent - val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: "" - Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) - } - - is RelayName -> { - Text( - text = option.name(), - fontSize = Font14SP, - color = MaterialTheme.colorScheme.onSurface, - ) + val noteState by observeNote(option.note, accountViewModel) + val name = remember(noteState) { option.name(context) } + Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is FavoriteAlgoFeedName -> { val noteState by observeNote(option.note, accountViewModel) - val name = - (noteState.note.event as? AppDefinitionEvent) - ?.appMetaData() - ?.name - ?.takeIf { it.isNotBlank() } ?: option.note.dTag() - Text( - text = name, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.onSurface, - ) + val name = remember(noteState) { option.name(context) } + Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } - is InterestSetName -> { + // Pure names: no relay subscription needed. + is HashtagName, + is ResourceName, + is RelayName, + is InterestSetName, + -> { Text( - text = option.name(), + text = option.name(context), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface, ) @@ -380,12 +330,6 @@ fun RenderOption( } } -@Immutable -private data class IndexedFeedDefinition( - val originalIndex: Int, - val item: FeedDefinition, -) - private enum class FeedGroup( @param:androidx.annotation.StringRes val labelRes: Int, ) { @@ -399,48 +343,51 @@ private enum class FeedGroup( RELAYS(R.string.feed_group_relays), } -private fun groupFeedDefinitions(options: ImmutableList): Map> { - val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) } - return indexed.groupBy { entry -> - when (entry.item.name) { - is HashtagName -> { - FeedGroup.HASHTAGS - } +private fun FeedDefinition.group(): FeedGroup = + when (name) { + is HashtagName -> { + FeedGroup.HASHTAGS + } - is CommunityName -> { - FeedGroup.COMMUNITIES - } + is CommunityName -> { + FeedGroup.COMMUNITIES + } - is PeopleListName -> { - FeedGroup.LISTS - } + is PeopleListName -> { + FeedGroup.LISTS + } - is RelayName -> { - FeedGroup.RELAYS - } + is RelayName -> { + FeedGroup.RELAYS + } - is GeoHashName -> { - FeedGroup.LOCATIONS - } + is GeoHashName -> { + FeedGroup.LOCATIONS + } - is FavoriteAlgoFeedName -> { - FeedGroup.DVMS - } + is FavoriteAlgoFeedName -> { + FeedGroup.DVMS + } - is InterestSetName -> { - FeedGroup.INTEREST_SETS - } + is InterestSetName -> { + FeedGroup.INTEREST_SETS + } - is ResourceName -> { - when (entry.item.code) { - is TopFilter.AroundMe -> FeedGroup.LOCATIONS - is TopFilter.Global -> FeedGroup.RELAYS - is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS - else -> FeedGroup.FEEDS - } + is ResourceName -> { + when (code) { + is TopFilter.AroundMe -> FeedGroup.LOCATIONS + is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS + else -> FeedGroup.FEEDS } } } + +private fun groupFeedDefinitions(options: ImmutableList): List>> { + val grouped = options.groupBy { it.group() } + return FeedGroup.entries.mapNotNull { group -> + grouped[group]?.takeIf { it.isNotEmpty() }?.let { group to it } + } } @OptIn(ExperimentalLayoutApi::class) @@ -448,7 +395,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map, - onSelect: (Int) -> Unit, + onSelect: (FeedDefinition) -> Unit, onDismiss: () -> Unit, onRenderItem: @Composable (FeedDefinition) -> Unit, ) { @@ -472,18 +419,15 @@ private fun GroupedFeedFilterDialog( ) } - FeedGroup.entries.forEach { group -> - val items = grouped[group] - if (!items.isNullOrEmpty()) { - item { - GroupSection( - label = stringRes(group.labelRes), - items = items, - isChipLayout = group == FeedGroup.HASHTAGS, - onSelect = onSelect, - onRenderItem = onRenderItem, - ) - } + grouped.forEach { (group, items) -> + item(key = group) { + GroupSection( + label = stringRes(group.labelRes), + items = items, + isChipLayout = group == FeedGroup.HASHTAGS, + onSelect = onSelect, + onRenderItem = onRenderItem, + ) } } } @@ -495,11 +439,12 @@ private fun GroupedFeedFilterDialog( @Composable private fun GroupSection( label: String, - items: List, + items: List, isChipLayout: Boolean, - onSelect: (Int) -> Unit, + onSelect: (FeedDefinition) -> Unit, onRenderItem: @Composable (FeedDefinition) -> Unit, ) { + val context = LocalContext.current Surface( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), shape = RoundedCornerShape(16.dp), @@ -523,13 +468,13 @@ private fun GroupSection( ) { items.forEach { entry -> Surface( - modifier = Modifier.clickable { onSelect(entry.originalIndex) }, + modifier = Modifier.clickable { onSelect(entry) }, shape = RoundedCornerShape(18.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), color = Color.Transparent, ) { Text( - text = entry.item.name.name(), + text = entry.name.name(context), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), @@ -545,15 +490,15 @@ private fun GroupSection( modifier = Modifier .fillMaxWidth() - .clickable { onSelect(entry.originalIndex) } + .clickable { onSelect(entry) } .padding(horizontal = 16.dp, vertical = 6.dp), ) { FeedIcon( - item = entry.item, + item = entry, modifier = Size20Modifier, ) - Spacer(modifier = Modifier.padding(start = 12.dp)) - Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { onRenderItem(entry) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index f703eaef9..d3346f3f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -332,7 +332,7 @@ fun RenderZapGallery( modifier = WidthAuthorPictureModifier, ) { ZappedIcon( - modifier = remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + modifier = Modifier.size(Size25dp).align(Alignment.TopEnd), ) } @@ -353,7 +353,7 @@ fun RenderBoostGallery( modifier = NotificationIconModifierSmaller, ) { RepostedIcon( - modifier = remember { Modifier.size(Size20dp).align(Alignment.TopEnd) }, + modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), ) } @@ -374,7 +374,7 @@ fun RenderBoostGallery( modifier = NotificationIconModifierSmaller, ) { RepostedIcon( - modifier = remember { Modifier.size(Size20dp).align(Alignment.TopEnd) }, + modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index fe2d96c5b..9b48d1d76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1513,7 +1513,7 @@ fun SecondUserInfoRow( verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight, ) { - Column(modifier = remember { Modifier.weight(1f) }) { + Column(modifier = Modifier.weight(1f)) { if (noteEvent is IForkableEvent && noteEvent.isAFork()) { ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 63177ae8f..f62a98b73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -136,7 +135,7 @@ fun UserComposeNoAction( ) { UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 04ccf503c..b0a160b8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -75,19 +74,19 @@ fun UserReactionsRow( ) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserReplyModel(model) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserBoostModel(model) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserReactionModel(model) } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { UserZapModel(model) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 3d3087aa6..2e91876a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -78,7 +77,7 @@ fun ZapUserSetCompose( modifier = Size55Modifier, ) { ZappedIcon( - remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + Modifier.size(Size25dp).align(Alignment.TopEnd), ) } } @@ -103,7 +102,7 @@ fun ZapUserSetCompose( nav = nav, ) - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(zapSetCard.user, accountViewModel = accountViewModel) } AboutDisplay(zapSetCard.user, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index 36cdecdcd..d25007493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.stringRes @@ -70,6 +71,7 @@ fun MessageField( state = viewModel.message, onTextChanged = viewModel::onMessageChanged, onContentReceived = onContentReceived, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 4e92df39b..603b499f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -31,8 +31,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -40,6 +43,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -50,12 +55,16 @@ 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.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -100,6 +109,38 @@ fun ImageVideoDescription( var message by remember { mutableStateOf("") } var sensitiveContent by remember { mutableStateOf(false) } + val context = LocalContext.current + val firstImageUri = + remember(uris) { + uris + .first() + .takeIf { it.media.isImage() == true && it.media.isGif().not() } + ?.media + ?.uri + } + val labelService = remember { MLKitImageLabelService(context.applicationContext) } + var isLabeling by remember { mutableStateOf(false) } + var aiSuggested by remember { mutableStateOf(false) } + + DisposableEffect(labelService) { + onDispose { labelService.close() } + } + + LaunchedEffect(firstImageUri) { + if (firstImageUri == null || message.isNotEmpty()) return@LaunchedEffect + isLabeling = true + val suggestion = + try { + labelService.suggestAltText(firstImageUri) + } finally { + isLabeling = false + } + if (suggestion != null && message.isEmpty()) { + message = suggestion + aiSuggested = true + } + } + // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by remember { mutableIntStateOf(if (uris.hasNonMedia()) 3 else 1) @@ -238,13 +279,24 @@ fun ImageVideoDescription( .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, - onValueChange = { message = it }, + onValueChange = { + message = it + aiSuggested = false + }, placeholder = { Text( text = stringRes(R.string.content_description_example), color = MaterialTheme.colorScheme.placeholderText, ) }, + trailingIcon = { + if (isLabeling) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -252,6 +304,38 @@ fun ImageVideoDescription( ) } + if (aiSuggested) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + AssistChip( + onClick = { + message = "" + aiSuggested = false + }, + label = { Text(text = stringRes(R.string.ai_suggested_alt_text_hint)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + trailingIcon = { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.ai_suggested_alt_text_dismiss), + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + ) + } + } + // Hide privacy toggle when any selected video will be compressed (compression already strips metadata) val isVideoWithCompression = uris.hasVideo() && mediaQualitySlider != 3 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index acf1df765..469f6fa9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -216,7 +216,7 @@ fun RenderAppDefinition( CreateTextWithEmoji( text = it, tags = - remember { + remember(note) { (note.event?.tags ?: emptyArray()).toImmutableListOfLists() }, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index 865d7c0b9..0c5a3a04e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -80,10 +80,10 @@ fun AudioTrackHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val media = remember { noteEvent.media() } - val cover = remember { noteEvent.cover() } - val subject = remember { noteEvent.subject() } - val participants = remember { noteEvent.participants() } + val media = remember(noteEvent) { noteEvent.media() } + val cover = remember(noteEvent) { noteEvent.cover() } + val subject = remember(noteEvent) { noteEvent.subject() } + val participants = remember(noteEvent) { noteEvent.participants() } var participantUsers by remember { mutableStateOf>>(emptyList()) } @@ -183,9 +183,9 @@ fun AudioHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } - val content = remember { noteEvent.content.ifBlank { null } } + val media = remember(noteEvent) { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember(noteEvent) { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } val defaultBackground = MaterialTheme.colorScheme.background val background = remember { mutableStateOf(defaultBackground) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index dea8fee72..816dc08e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -51,8 +51,14 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +private val PriceTagModifier = + Modifier + .clip(SmallBorder) + .padding(start = 5.dp) + @Composable fun RenderClassifieds( noteEvent: ClassifiedsEvent, @@ -60,23 +66,31 @@ fun RenderClassifieds( accountViewModel: AccountViewModel, nav: INav, ) { - val imageSet = - noteEvent.imageMetas().ifEmpty { null }?.map { - MediaUrlImage( - url = it.url, - description = it.alt, - hash = it.hash, - blurhash = it.blurhash, - dim = it.dimension, - uri = note.toNostrUri(), - mimeType = it.mimeType, - thumbhash = it.thumbhash, - ) + val imageSet: ImmutableList? = + remember(noteEvent) { + noteEvent + .imageMetas() + .ifEmpty { null } + ?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + thumbhash = it.thumbhash, + ) + }?.toImmutableList() } - val title = noteEvent.title() - val summary = noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } - val price = noteEvent.price() - val location = noteEvent.location() + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } + } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } Row( modifier = @@ -94,7 +108,7 @@ fun RenderClassifieds( AutoNonlazyGrid(images.size) { ZoomableContentView( content = images[it], - images = images.toImmutableList(), + images = images, roundedCorner = false, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, @@ -140,12 +154,7 @@ fun RenderClassifieds( maxLines = 1, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .padding(start = 5.dp) - }, + modifier = PriceTagModifier, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 4ad82bbff..62d91d39a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -46,7 +44,7 @@ fun FileHeaderDisplay( val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return - val content by + val content: BaseMediaContent = remember(note) { val blurHash = event.blurhash() val thumbHash = event.thumbhash() @@ -57,32 +55,30 @@ fun FileHeaderDisplay( val uri = note.toNostrUri() val mimeType = event.mimeType() - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - }, - ) + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + thumbhash = thumbHash, + ) + } } SensitivityWarning(note = note, accountViewModel = accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 26a947d0c..40f080cfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -357,7 +357,7 @@ fun DisplayEntryForAUrl( } val validatedUrl = - remember { + remember(url) { try { URL(url) } catch (_: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index f36864286..6ba446027 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlinx.collections.immutable.toImmutableList private const val WORDS_PER_MINUTE = 225 private val COVER_ASPECT_RATIO = 16f / 9f @@ -92,7 +93,14 @@ fun LongFormHeader( remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } - val topics = remember(noteEvent) { noteEvent.topics().distinct().take(3) } + val topics = + remember(noteEvent) { + noteEvent + .topics() + .distinct() + .take(3) + .toImmutableList() + } val readingMinutes = remember(noteEvent) { estimateReadingMinutes(noteEvent.content) } Column(MaterialTheme.colorScheme.replyModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index a6aaa9c1f..cfc1b3ca5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -341,6 +341,24 @@ private fun RenderParticipants( } } +private val MeetingSpaceOpenModifier = + Modifier + .clip(SmallBorder) + .background(Color(0xFF4CAF50)) + .padding(horizontal = 5.dp) + +private val MeetingSpacePrivateModifier = + Modifier + .clip(SmallBorder) + .background(Color(0xFFFF9800)) + .padding(horizontal = 5.dp) + +private val MeetingSpaceClosedModifier = + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + @Composable fun MeetingSpaceOpenFlag() { Text( @@ -348,13 +366,7 @@ fun MeetingSpaceOpenFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color(0xFF4CAF50)) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpaceOpenModifier, ) } @@ -365,13 +377,7 @@ fun MeetingSpacePrivateFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color(0xFFFF9800)) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpacePrivateModifier, ) } @@ -382,13 +388,7 @@ fun MeetingSpaceClosedFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpaceClosedModifier, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index 5f73c4c0c..91ea6a7a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -54,7 +54,6 @@ fun RenderNIP90ContentDiscoveryResponse( note = note, accountViewModel = accountViewModel, ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } @@ -62,7 +61,7 @@ fun RenderNIP90ContentDiscoveryResponse( content = noteEvent.content, canPreview = canPreview && !makeItShort, quotesLeft = quotesLeft, - modifier = modifier, + modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, id = note.idHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index dbd7fb2d9..93abb98b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -81,7 +81,7 @@ fun DisplayPeopleList( members.take(3) } - val name by remember { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } + val name by remember(noteEvent) { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } Text( text = name, @@ -95,7 +95,7 @@ fun DisplayPeopleList( textAlign = TextAlign.Center, ) - LaunchedEffect(Unit) { + LaunchedEffect(noteEvent) { accountViewModel.loadUsers(noteEvent.taggedUserIds()) { members = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 3015959a2..f5fc59495 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme 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.ui.Modifier import androidx.compose.ui.graphics.Color @@ -71,30 +69,29 @@ fun PictureDisplay( val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val images by + val images = remember(note) { - mutableStateOf( - event - .imetaTags() - .map { - MediaUrlImage( - url = it.url, - description = it.alt, - hash = it.hash, - blurhash = it.blurhash, - dim = it.dimension, - uri = uri, - mimeType = it.mimeType, - thumbhash = it.thumbhash, - ) - }.toImmutableList(), - ) + event + .imetaTags() + .map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = uri, + mimeType = it.mimeType, + thumbhash = it.thumbhash, + ) + }.toImmutableList() } val first = images.firstOrNull() if (first != null) { - val title = event.title() + val title = remember(event) { event.title() } + val preloadUrls = remember(images) { images.map { it.url } } Column { if (title != null) { @@ -114,7 +111,7 @@ fun PictureDisplay( ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - preloadUrls = listOf(first.url), + preloadUrls = preloadUrls, accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } }, @@ -131,7 +128,7 @@ fun PictureDisplay( ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - preloadUrls = images.map { it.url }, + preloadUrls = preloadUrls, accountViewModel = accountViewModel, modifier = Modifier.fillMaxWidth().aspectRatio(1f), backdrop = { BlurhashGridBackdrop(images) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 105a7c898..9f19533c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -65,7 +65,7 @@ fun RenderPinListEvent( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pinnedEvents()) } + val pins = remember(noteEvent) { noteEvent.pinnedEvents() } var expanded by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 8edda46dd..6b7d0896a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -358,7 +358,7 @@ private fun ColumnScope.RenderSingleChoiceOptions( verticalAlignment = Alignment.CenterVertically, ) { val hasSpaceToClick = - remember { + remember(it.label) { it.label.contains(' ') || it.label.contains('\n') } @@ -436,7 +436,7 @@ private fun RenderResults( labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { val showGallery = - remember { + remember(card) { card.options.all { it.label.length < 50 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index 80c232c97..eec7c4b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -85,12 +85,11 @@ fun RenderPrivateMessage( } } - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + val withMe = remember(noteEvent) { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } if (withMe) { LoadDecryptedContent(note, accountViewModel) { eventContent -> - val modifier = remember(note.event?.id) { Modifier.fillMaxWidth() } val isAuthorTheLoggedUser = - remember(note.event?.id) { accountViewModel.isLoggedUser(note.author) } + remember(note.author) { accountViewModel.isLoggedUser(note.author) } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } @@ -113,7 +112,7 @@ fun RenderPrivateMessage( content = eventContent, canPreview = canPreview && !makeItShort, quotesLeft = quotesLeft, - modifier = modifier, + modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, id = note.idHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index ca15807f1..6b1521866 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -75,12 +75,10 @@ fun DisplayRelaySet( ) { val noteEvent = baseNote.event as? RelaySetEvent ?: return - val relays by + val relays = remember(noteEvent) { - mutableStateOf( - RelayListCard( - noteEvent.relays().toImmutableList(), - ), + RelayListCard( + noteEvent.relays().toImmutableList(), ) } @@ -89,10 +87,12 @@ fun DisplayRelaySet( noteEvent.tags.firstTagValueFor("title", "name") ?: "#${noteEvent.dTag()}" } + val description = remember(noteEvent) { noteEvent.description() } + DisplayRelaySet( relays, relayListName, - noteEvent.description(), + description, backgroundColor, accountViewModel, nav, @@ -108,21 +108,17 @@ fun DisplayNIP65RelayList( ) { val noteEvent = baseNote.event as? AdvertisedRelayListEvent ?: return - val writeRelays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.writeRelaysNorm() ?: emptyList(), - ), + val writeRelays = + remember(noteEvent) { + RelayListCard( + noteEvent.writeRelaysNorm() ?: emptyList(), ) } - val readRelays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.readRelaysNorm() ?: emptyList(), - ), + val readRelays = + remember(noteEvent) { + RelayListCard( + noteEvent.readRelaysNorm() ?: emptyList(), ) } @@ -154,12 +150,10 @@ fun DisplayDMRelayList( ) { val noteEvent = baseNote.event as? ChatMessageRelayListEvent ?: return - val relays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.relays(), - ), + val relays = + remember(noteEvent) { + RelayListCard( + noteEvent.relays(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt index 7585c9b55..49981b8de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -48,35 +48,43 @@ fun RenderReport( ) { val noteEvent = note.event as? ReportEvent ?: return - val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } + val reportTypes = + remember(noteEvent) { + (noteEvent.reportedPost() + noteEvent.reportedAuthor()) + .mapTo(LinkedHashSet()) { it.type } + } - val reportType = - base - .map { - when (it.type) { - ReportType.EXPLICIT -> stringRes(R.string.explicit_content) - ReportType.NUDITY -> stringRes(R.string.nudity) - ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech) - ReportType.SPAM -> stringRes(R.string.spam) - ReportType.IMPERSONATION -> stringRes(R.string.impersonation) - ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior) - ReportType.MALWARE -> stringRes(R.string.malware) - ReportType.OTHER -> stringRes(R.string.other) - ReportType.HARASSMENT -> stringRes(R.string.harassment) - ReportType.VIOLENCE -> stringRes(R.string.violence) - null -> stringRes(R.string.other) - } - }.toSet() - .joinToString(", ") + val explicitContent = stringRes(R.string.explicit_content) + val nudity = stringRes(R.string.nudity) + val profanity = stringRes(R.string.profanity_hateful_speech) + val spam = stringRes(R.string.spam) + val impersonation = stringRes(R.string.impersonation) + val illegal = stringRes(R.string.illegal_behavior) + val malware = stringRes(R.string.malware) + val other = stringRes(R.string.other) + val harassment = stringRes(R.string.harassment) + val violence = stringRes(R.string.violence) val content = - remember { - reportType + ( - note.event - ?.content - ?.ifBlank { null } - ?.let { ": $it" } ?: "" - ) + remember(reportTypes, noteEvent) { + val reportTypeText = + reportTypes.joinToString(", ") { + when (it) { + ReportType.EXPLICIT -> explicitContent + ReportType.NUDITY -> nudity + ReportType.PROFANITY -> profanity + ReportType.SPAM -> spam + ReportType.IMPERSONATION -> impersonation + ReportType.ILLEGAL -> illegal + ReportType.MALWARE -> malware + ReportType.OTHER -> other + ReportType.HARASSMENT -> harassment + ReportType.VIOLENCE -> violence + null -> other + } + } + val extra = noteEvent.content.ifBlank { null }?.let { ": $it" } ?: "" + reportTypeText + extra } TranslatableRichTextViewer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index 49ebe1cbb..de4f5c3ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -153,19 +153,24 @@ fun RenderTorrent( ) { val noteEvent = note.event as? TorrentEvent ?: return - val name = (noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION) - val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" - - val description = - if (noteEvent.content != name) { - noteEvent.content - } else { - null + val title = + remember(noteEvent) { + val name = noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION + val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" + name + size } + val description = + remember(noteEvent) { + val name = noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION + if (noteEvent.content != name) noteEvent.content else null + } + + val files = remember(noteEvent) { noteEvent.files().toImmutableList() } + DisplayFileList( - noteEvent.files().toImmutableList(), - name + size, + files, + title, description, noteEvent::toMagnetLink, backgroundColor, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 1c2399218..d00bd6d2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme 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.ui.Alignment import androidx.compose.ui.Modifier @@ -81,45 +79,43 @@ fun VideoDisplay( val imeta = videoEvent.imetaTags().firstOrNull() ?: return - val title = videoEvent.title() - val summary = videoEvent.content.ifBlank { null }?.takeIf { title != it } - val image = imeta.image.firstOrNull() - val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") + val title = remember(videoEvent) { videoEvent.title() } + val summary = remember(videoEvent, title) { videoEvent.content.ifBlank { null }?.takeIf { title != it } } + val image = remember(imeta) { imeta.image.firstOrNull() } + val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } - val content by + val content: BaseMediaContent = remember(note) { val description = videoEvent.content.ifBlank { null } ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = imeta.url, - description = description, - hash = imeta.hash, - blurhash = imeta.blurhash, - dim = imeta.dimension, - uri = uri, - mimeType = imeta.mimeType, - thumbhash = imeta.thumbhash, - ) - } else { - MediaUrlVideo( - url = imeta.url, - description = description, - hash = imeta.hash, - dim = imeta.dimension, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = imeta.image.firstOrNull(), - mimeType = imeta.mimeType, - blurhash = imeta.blurhash, - thumbhash = imeta.thumbhash, - ) - }, - ) + if (isImage) { + MediaUrlImage( + url = imeta.url, + description = description, + hash = imeta.hash, + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = uri, + mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, + ) + } else { + MediaUrlVideo( + url = imeta.url, + description = description, + hash = imeta.hash, + dim = imeta.dimension, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = imeta.image.firstOrNull(), + mimeType = imeta.mimeType, + blurhash = imeta.blurhash, + thumbhash = imeta.thumbhash, + ) + } } SensitivityWarning(note = note, accountViewModel = accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 775e046f3..a39ea48c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.annotation.SuppressLint import android.content.Context -import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper import android.util.LruCache @@ -34,9 +33,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import coil3.asDrawable -import coil3.imageLoader -import coil3.request.ImageRequest import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences @@ -1580,29 +1576,6 @@ class AccountViewModel( super.onCleared() } - fun loadThumb( - context: Context, - thumbUri: String, - onReady: (Drawable?) -> Unit, - onError: (String?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val request = ImageRequest.Builder(context).data(thumbUri).build() - val myCover = - context.imageLoader - .execute(request) - .image - ?.asDrawable(context.resources) - onReady(myCover) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("VideoView", "Fail to load cover $thumbUri", e) - onError(e.message) - } - } - } - fun loadMentions( mentions: ImmutableList, onReady: (ImmutableList) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt index 80365369c..53bc0b952 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt @@ -64,7 +64,7 @@ private fun ArticlesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt index 522a6d765..9a9974166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesTopBar.kt @@ -64,7 +64,7 @@ private fun BadgesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 270113bd9..af68085c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -530,6 +531,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) @@ -572,6 +574,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.subject, onTextChanged = { postViewModel.onSubjectChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index da9d8ed21..51b5bbd3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -203,6 +204,7 @@ fun EditField( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = PostKeyboard, shape = EditFieldBorder, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 08441b657..f6d672af5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -167,7 +167,7 @@ fun LongPublicChatChannelHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) + NormalTimeAgo(note, Modifier.weight(1f)) MoreOptionsButton(note, null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt index 941fcfbc8..b7be2f297 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LongLiveActivityChannelHeader.kt @@ -116,7 +116,7 @@ fun LongLiveActivityChannelHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) + NormalTimeAgo(note, Modifier.weight(1f)) MoreOptionsButton(note, null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 1c2669b35..c9e63c90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -116,6 +117,7 @@ fun EditFieldRow( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index b702de4d4..b852165f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -25,14 +25,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -45,6 +47,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @Composable fun ChatroomListFeedView( @@ -103,14 +113,16 @@ private fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + val myPubKey = accountViewModel.userProfile().pubkeyHex + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - itemsIndexed( + items( items.list, - key = { index, item -> if (index == 0) index else item.idHex }, - ) { _, item -> + key = { item -> chatroomLazyKey(item, myPubKey) }, + ) { item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -125,3 +137,65 @@ private fun FeedLoaded( } } } + +// Stable per-chatroom key — derived from chatroom identity, not the latest +// message id, so reorders move the row instead of recreating it. Uses a +// sealed wrapper around an existing String/RoomId/ChatroomKey to avoid the +// StringBuilder + concatenation allocations of a typed-prefix string key. +private sealed interface ChatroomLazyKey + +private data class MarmotChatroomLazyKey( + val groupId: HexKey, +) : ChatroomLazyKey + +private data class PublicChannelLazyKey( + val channelId: HexKey, +) : ChatroomLazyKey + +private data class EphemeralChannelLazyKey( + val roomId: RoomId, +) : ChatroomLazyKey + +private data class PrivateChatLazyKey( + val key: ChatroomKey, +) : ChatroomLazyKey + +private data class FallbackChatroomLazyKey( + val noteIdHex: HexKey, +) : ChatroomLazyKey + +private fun chatroomLazyKey( + item: Note, + myPubKey: HexKey, +): ChatroomLazyKey { + item.inGatherers + ?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } + ?.let { return MarmotChatroomLazyKey(it.nostrGroupId) } + + return when (val event = item.event) { + is ChannelMessageEvent -> { + PublicChannelLazyKey(event.channelId() ?: item.idHex) + } + + is ChannelMetadataEvent -> { + PublicChannelLazyKey(event.channelId() ?: item.idHex) + } + + is ChannelCreateEvent -> { + PublicChannelLazyKey(event.id) + } + + is EphemeralChatEvent -> { + event.roomId()?.let { EphemeralChannelLazyKey(it) } + ?: FallbackChatroomLazyKey(item.idHex) + } + + is ChatroomKeyable -> { + PrivateChatLazyKey(event.chatroomKey(myPubKey)) + } + + else -> { + FallbackChatroomLazyKey(item.idHex) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt index 2896d82b2..5648c69fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -57,7 +56,7 @@ fun DisplayReplyingToNote( .animateContentSize(), ) { if (replyingNote != null) { - Column(remember { Modifier.weight(1f) }) { + Column(Modifier.weight(1f)) { ChatroomMessageCompose( baseNote = replyingNote, null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt index 5c8957f05..641c1db90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt @@ -64,7 +64,7 @@ private fun CommunitiesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 4c3b57af3..668606b31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -64,7 +64,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 87977bdc4..35296e88d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle @@ -381,6 +382,7 @@ private fun MarkdownPostScreenBody( ThinPaddingTextField( state = postViewModel.message, onTextChanged = postViewModel::onMessageChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index c13346b81..52dfc08ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -111,6 +112,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.title, onTextChanged = { postViewModel.onTitleChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -311,6 +313,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.locationText, onTextChanged = { postViewModel.onLocationChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt index abde6ead6..8bc6e8460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt @@ -64,7 +64,7 @@ private fun BrowseEmojiSetsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt index a29cf8434..bf4d82d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt @@ -64,7 +64,7 @@ private fun FollowPacksTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index 59e23c43c..3140f5880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -69,7 +69,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt index 656465430..147830473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/LiveStreamsTopBar.kt @@ -64,7 +64,7 @@ private fun LiveStreamsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt index eac3b6b4f..dfcf802bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt @@ -64,7 +64,7 @@ private fun LongsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsTopBar.kt index e4e8221bd..df987f1e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsTopBar.kt @@ -64,7 +64,7 @@ private fun NestsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 59cd27aef..2e377f37c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -64,7 +64,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 1519ac398..06d46689e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -417,6 +418,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt index 5acf6cd50..bef51851b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt @@ -64,7 +64,7 @@ private fun PicturesTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt index dd05a7922..66437b1d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsTopBar.kt @@ -64,7 +64,7 @@ private fun PollsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt index 12a404836..7ea108939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsTopBar.kt @@ -64,7 +64,7 @@ private fun ProductsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt index 98142ef97..a46e7f9e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsTopBar.kt @@ -64,7 +64,7 @@ private fun PublicChatsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt index 2a50c7c69..4c0ccc852 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt @@ -64,7 +64,7 @@ private fun ShortsTopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index f79e2c3a6..6ff45b1f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -539,7 +539,7 @@ private fun FullBleedNoteCompose( Row(verticalAlignment = Alignment.CenterVertically) { Column( - remember { Modifier.weight(1f) }, + Modifier.weight(1f), ) { if (noteEvent is IForkableEvent && noteEvent.isAFork()) { ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index d74ab4e67..5f3bffbf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -65,7 +65,7 @@ private fun TopNavFilterBar( placeholderCode = listName, explainer = stringRes(R.string.select_list_to_filter), options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + onSelect = onChange, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 29f7c66fe..c8e57da39 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -832,7 +832,6 @@ Amethyst oznámení aktivní Připojeno k %1$d inbox relayím Připojování k inbox relayím\u2026 - Pozastavit Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. Optimalizace baterie aktivní diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 1a95a0613..78ac890e8 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -837,7 +837,6 @@ anz der Bedingungen ist erforderlich Amethyst-Benachrichtigungen aktiv Mit %1$d Inbox-Relays verbunden Verbinde mit Inbox-Relays\u2026 - Pausieren Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. Akkuoptimierung aktiv diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index ebf606d4a..4d7d5c6d3 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -836,7 +836,6 @@ अमेथिस्ट सूचनाएँ सक्रिय संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 - विराम सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। विद्युत्कोष अनुकूलन सक्रिय @@ -2199,6 +2198,8 @@ एआई॰ लेखन सहायता लेख शोधन प्रस्ताव करें यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए। + पदचिह्न युक्त प्रसारण + पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। तत्काल प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। इसका प्रयोग करें हटाएँ सम्यक diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 6ebed89e3..ceb91302b 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -836,7 +836,6 @@ Amethyst értesítések aktíválva Kapcsolódva %1$d beérkező üzenetátjátszóhoz Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 - Szüneteltetés Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. Akkumulátor-optimalizálás aktív @@ -2199,6 +2198,8 @@ Súgó az LLM-alapú íráshoz Javaslatok a szöveg javítására Az eszközön futó LLM-modell segítségével szövegjavításokkal és hangnemváltoztatásokkal kapcsolatos javaslatokat kaphat. + Nyomon követhető közvetítések + Nyomon követhető közvetítők használata az események küldesékor. Közvetítés közben megjeleníti a jelenlegi előrehaladást és az egyes átjátszók állapotát. Ennek használata Eltüntetés Javítás diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 9f5109a0a..65ed8af92 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -833,7 +833,6 @@ Powiadomienia Ametyst Aktywne Połączono z %1$d transmiterami odbiorczymi Łączenie z transmiterami odbiorczymi\u2026 - Pauza Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. Optymalizacja baterii aktywna @@ -1147,8 +1146,8 @@ Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 - Podziel zapsy z - Przesyłanie zapasów do + Podziel zapy z + Przesyłanie zapów do Nie znaleziono portfeli Lightning Zapłacone Portfel %1$s @@ -2196,6 +2195,8 @@ Pomoc w pisaniu z użyciem AI Zaproponuj poprawki w tekście Używa modelu sztucznej inteligencji wbudowanego w urządzenie, proponując poprawki tekstu i zmiany tonu wypowiedzi. + Monitorowane transmisje + Podczas wysyłania zdarzeń korzystaj z monitora transmisji. Pokazuje postęp na żywo i status transmisji podczas nadawania. Użyj tego Ignoruj Popraw diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7d5258142..3eef72024 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -832,7 +832,6 @@ Notificações do Amethyst ativas Conectado a %1$d relays de caixa de entrada Conectando aos relays de caixa de entrada\u2026 - Pausar Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. Otimização de bateria ativa diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 391fad54d..c62c2171f 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -847,7 +847,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Amethyst obvestila so aktivna Povezan z %1$d vhodnimi releji Povezovanje vhodnih relejev\u2026 - Premor Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. Optimizacija baterije je aktivna diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index f6625578a..ed2560dcd 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -831,7 +831,6 @@ Amethyst-notifieringar aktiva Ansluten till %1$d inbox-relän Ansluter till inbox-relän\u2026 - Pausa Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. Batterioptimering aktiv diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index f0a505d31..e3515de0e 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -836,7 +836,6 @@ Amethyst 通知活跃 已连接到 %1$d 个收件箱中继 正在连接到收件箱中继\u2026 - 暂停 始终开启通知服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 电池优化已启用 @@ -2199,6 +2198,8 @@ AI 写入帮助 建议文本改进 使用设备上的 AI 模型来提出文本更正和音调更改。 + 已跟踪的广播 + 在发送事件时使用已跟踪的广播。在广播时显示实时进度和每个中继的状态。 使用它 忽略 更正 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5faa6fc6a..63ee3e1fa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -729,6 +729,8 @@ Description of the contents A blue boat in a white sandy beach at sunset + AI-suggested, edit me + Dismiss AI suggestion Zap Type Zap Type for all options @@ -1037,7 +1039,6 @@ Amethyst Notifications Active Connected to %1$d inbox relays Connecting to inbox relays\u2026 - Pause Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt new file mode 100644 index 000000000..25d044b34 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitImageLabelService.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.ai + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.imagedescription.ImageDescriber +import com.google.mlkit.genai.imagedescription.ImageDescriberOptions +import com.google.mlkit.genai.imagedescription.ImageDescription +import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.label.ImageLabeler +import com.google.mlkit.vision.label.ImageLabeling +import com.google.mlkit.vision.label.defaults.ImageLabelerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +/** + * Unified alt-text suggestion service. + * + * Prefers Gemini-Nano-backed `genai-image-description` for full descriptive sentences when + * the device supports AICore; falls back to the legacy keyword `image-labeling` model otherwise. + */ +class MLKitImageLabelService( + private val context: Context, +) { + private var labeler: ImageLabeler? = null + private var describer: ImageDescriber? = null + + // FeatureStatus is an Int enum. Cached per-instance — describer availability does not flip + // mid-session in practice, and one composer mount only needs to ask AICore once. + @Volatile private var cachedGenAiStatus: Int? = null + + private fun ensureLabeler(): ImageLabeler = labeler ?: ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS).also { labeler = it } + + private fun ensureDescriber(): ImageDescriber? = + describer + ?: try { + ImageDescription + .getClient(ImageDescriberOptions.builder(context).build()) + .also { describer = it } + } catch (_: Exception) { + null + } + + suspend fun labelImage(uri: Uri): List> = + withContext(Dispatchers.IO) { + try { + val image = InputImage.fromFilePath(context, uri) + val client = ensureLabeler() + suspendCancellableCoroutine { cont -> + client + .process(image) + .addOnSuccessListener { labels -> + cont.resume(labels.map { it.text to it.confidence }) + }.addOnFailureListener { + cont.resume(emptyList()) + } + } + } catch (_: Exception) { + emptyList() + } + } + + suspend fun suggestAltText(uri: Uri): String? = describeWithGenAi(uri) ?: labelKeywords(uri) + + private suspend fun describeWithGenAi(uri: Uri): String? = + withContext(Dispatchers.IO) { + val client = ensureDescriber() ?: return@withContext null + try { + val status = + cachedGenAiStatus ?: client.checkFeatureStatus().get().also { cachedGenAiStatus = it } + if (status != FeatureStatus.AVAILABLE) return@withContext null + val bitmap = loadDownscaledBitmap(uri) ?: return@withContext null + val request = ImageDescriptionRequest.builder(bitmap).build() + client + .runInference(request) + .get() + .description + ?.trim() + ?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + } + + private suspend fun labelKeywords(uri: Uri): String? { + val labels = labelImage(uri) + val confident = labels.filter { it.second >= MIN_CONFIDENCE }.map { it.first } + if (confident.isEmpty()) return null + return confident.take(MAX_LABELS).joinToString(", ") + } + + // Two-pass decode keeps a 12 MP camera shot from blowing past 40 MB of ARGB_8888 — the + // on-device describer downscales internally anyway, so a ~1024 px input is plenty. + private fun loadDownscaledBitmap(uri: Uri): Bitmap? = + try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + val opts = + BitmapFactory.Options().apply { + inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, TARGET_DIM_PX) + } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + } catch (_: Exception) { + null + } + + private fun sampleSizeFor( + width: Int, + height: Int, + target: Int, + ): Int { + if (width <= 0 || height <= 0) return 1 + var sample = 1 + var maxDim = maxOf(width, height) + while (maxDim / sample > target) sample *= 2 + return sample + } + + fun close() { + labeler?.close() + labeler = null + describer?.close() + describer = null + cachedGenAiStatus = null + } + + companion object { + private const val MIN_CONFIDENCE = 0.6f + private const val MAX_LABELS = 5 + private const val TARGET_DIM_PX = 1024 + } +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index ccf648e46..9f5d9f008 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -31,11 +31,9 @@ import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -import kotlinx.coroutines.CancellationException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.regex.Pattern @Immutable data class ResultOrError( @@ -45,21 +43,16 @@ data class ResultOrError( ) object LanguageTranslatorService { - var executorService: ExecutorService = Executors.newCachedThreadPool() + private val executorService: ExecutorService = + Executors.newFixedThreadPool(maxOf(2, Runtime.getRuntime().availableProcessors() / 2)) - private val options = + private val identificationOptions = LanguageIdentificationOptions .Builder() .setExecutor(executorService) .setConfidenceThreshold(0.6f) .build() - private val languageIdentification = LanguageIdentification.getClient(options) - val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) - val tagRegex: Pattern = - Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", - Pattern.CASE_INSENSITIVE, - ) + private val languageIdentification = LanguageIdentification.getClient(identificationOptions) private val translators = object : LruCache(3) { @@ -75,8 +68,20 @@ object LanguageTranslatorService { } } + private data class InFlightKey( + val text: String, + val translateTo: String, + val dontTranslateFrom: Set, + ) + + // Coalesces concurrent translation requests for the same (content, settings) — the same note + // shown in N composables (reposts, notifications) only fires one ML Kit pipeline. + private val inFlight = ConcurrentHashMap>() + fun clear() { translators.evictAll() + inFlight.clear() + TranslationsCache.clear() } fun identifyLanguage(text: String): Task = languageIdentification.identifyLanguage(text) @@ -107,108 +112,51 @@ object LanguageTranslatorService { return translator.downloadModelIfNeeded().onSuccessTask(executorService) { checkNotInMainThread() - val tasks = mutableListOf>() - val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) - for (paragraph in encodeDictionary(text, dict).split("\n")) { - tasks.add(translator.translate(paragraph)) - } - - Tasks.whenAll(tasks).continueWith(executorService) { - checkNotInMainThread() - - val results: MutableList = ArrayList() - for (task in tasks) { - val fixedText = - task.result.replace("# [", "#[") // fixes tags that always return with a space - results.add(decodeDictionary(fixedText, dict)) - } - ResultOrError(results.joinToString("\n"), source, target) + translator.translate(encoded).continueWith(executorService) { task -> + task.exception?.let { throw it } + ResultOrError(TranslationDictionary.decode(task.result, dict), source, target) } } } - private fun encodeDictionary( - text: String, - dict: Map, - ): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.value, pair.key, true) - } - return newText - } - - private fun decodeDictionary( - text: String, - dict: Map, - ): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.key, pair.value, true) - } - return newText - } - - private fun tagDictionary(text: String): Map { - val matcher = tagRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val tag = matcher.group() - val short = "C$counter" - counter++ - returningList.put(short, tag) - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } - return returningList - } - - private fun lnDictionary(text: String): Map { - val matcher = lnRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val lnInvoice = matcher.group() - val short = "A$counter" - counter++ - returningList.put(short, lnInvoice) - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } - return returningList - } - - private fun urlDictionary(text: String): Map { - val urlsInText = UrlDetector(text).detect() - - var counter = 0 - - return urlsInText - .filter { !it.originalUrl.contains(",") && !it.originalUrl.contains("。") } - .associate { - counter++ - "B$counter" to it.originalUrl - } - } - fun autoTranslate( text: String, dontTranslateFrom: Set, translateTo: String, - ): Task = - identifyLanguage(text).onSuccessTask(executorService) { - if (it.equals(translateTo, true)) { - Tasks.forCanceled() - } else if (it != "und" && !dontTranslateFrom.contains(it)) { - translate(text, it, translateTo) - } else { - Tasks.forCanceled() + ): Task { + if (!TranslationDictionary.isWorthTranslating(text)) return Tasks.forCanceled() + return dedupe(InFlightKey(text, translateTo, dontTranslateFrom)) { + identifyLanguage(text).onSuccessTask(executorService) { detected -> + translateOrSkip(text, detected, dontTranslateFrom, translateTo) } } + } + + private fun translateOrSkip( + text: String, + detected: String, + dontTranslateFrom: Set, + translateTo: String, + ): Task = + when { + detected == "und" -> Tasks.forCanceled() + detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled() + detected in dontTranslateFrom -> Tasks.forCanceled() + else -> translate(text, detected, translateTo) + } + + private inline fun dedupe( + key: InFlightKey, + factory: () -> Task, + ): Task { + inFlight[key]?.let { return it } + val candidate = factory() + // putIfAbsent guards against a racing caller: keep the winner, drop the loser. + val winner = inFlight.putIfAbsent(key, candidate) ?: candidate + winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) } + return winner + } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt new file mode 100644 index 000000000..5a504ebbe --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionary.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.lang + +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import java.util.regex.Pattern + +/** + * Pure-JVM helpers that protect non-translatable substrings (URLs, Lightning invoices, NIP-19 + * references, NIP-08 positional references) by swapping them with single Unicode Private Use Area + * codepoints around a translator. Extracted out of [LanguageTranslatorService] so the round-trip + * can be unit-tested without ML Kit / Android runtime. + */ +internal object TranslationDictionary { + // Range U+E000..U+F8FF gives 6400 placeholder slots. PUA codepoints don't appear in normal + // user text, the translator has no rule for them so it passes them through, and using one + // codepoint per placeholder means the translator can't split or reorder it. + const val PLACEHOLDER_BASE: Int = 0xE000 + const val PLACEHOLDER_LIMIT: Int = 0xF8FF - PLACEHOLDER_BASE + + // Texts shorter than this, or with no letter codepoints (emoji-only, punctuation), are skipped + // before any ML Kit work — language identification is unreliable on them. + private const val MIN_TRANSLATABLE_LENGTH = 4 + + val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) + val tagRegex: Pattern = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + Pattern.CASE_INSENSITIVE, + ) + + // Legacy NIP-08 positional references like #[0]. Translators tend to insert a space inside the + // brackets ("# [0]"), so we shield them via the placeholder dictionary. + val nip08RefRegex: Pattern = Pattern.compile("#\\[\\d+]") + + fun isWorthTranslating(text: String): Boolean { + if (text.length < MIN_TRANSLATABLE_LENGTH) return false + for (cp in text.codePoints()) { + if (Character.isLetter(cp)) return true + } + return false + } + + fun build(text: String): Map { + val dict = LinkedHashMap() + var counter = 0 + + fun addUnique(value: String) { + if (value.isEmpty()) return + if (counter > PLACEHOLDER_LIMIT) return + if (dict.containsValue(value)) return + dict[placeholder(counter++)] = value + } + + lnRegex.forEachMatch(text, ::addUnique) + tagRegex.forEachMatch(text, ::addUnique) + nip08RefRegex.forEachMatch(text, ::addUnique) + + for (url in UrlDetector(text).detect()) { + val original = url.originalUrl + // The URL detector greedily includes Chinese full-width punctuation; skip those false hits. + if (original.contains(',') || original.contains('。')) continue + addUnique(original) + } + + return dict + } + + private inline fun Pattern.forEachMatch( + text: String, + block: (String) -> Unit, + ) { + val matcher = matcher(text) + while (matcher.find()) block(matcher.group()) + } + + fun encode( + text: String, + dict: Map, + ): String { + if (dict.isEmpty()) return text + var newText = text + // Replace longest values first so a URL prefix never clobbers a longer URL or tag. + for ((token, original) in dict.entries.sortedByDescending { it.value.length }) { + newText = newText.replace(original, token, ignoreCase = false) + } + return newText + } + + fun decode( + text: String?, + dict: Map, + ): String? { + if (text == null || dict.isEmpty()) return text + var newText: String = text + for ((token, original) in dict) { + newText = newText.replace(token, original, ignoreCase = false) + } + return newText + } + + fun placeholder(index: Int): String { + require(index in 0..PLACEHOLDER_LIMIT) { "placeholder index $index out of range" } + return String(Character.toChars(PLACEHOLDER_BASE + index)) + } +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt index f4f61f5e3..dfca9372a 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt @@ -24,14 +24,34 @@ import android.util.LruCache import com.vitorpamplona.amethyst.ui.components.TranslationConfig object TranslationsCache { - val cache = LruCache(100) + private const val MAX_ENTRIES = 500 - fun get(content: String): TranslationConfig = cache.get(content) ?: TranslationConfig(content, null, null, false) + // Keying on the language settings as well prevents serving stale translations after the user + // changes "Translate to" or "Don't translate from". + private data class Key( + val content: String, + val translateTo: String, + val dontTranslateFrom: Set, + ) + + private val cache = LruCache(MAX_ENTRIES) + + fun get( + content: String, + translateTo: String, + dontTranslateFrom: Set, + ): TranslationConfig? = cache.get(Key(content, translateTo, dontTranslateFrom)) fun set( content: String, + translateTo: String, + dontTranslateFrom: Set, config: TranslationConfig, ) { - cache.put(content, config) + cache.put(Key(content, translateTo, dontTranslateFrom), config) + } + + fun clear() { + cache.evictAll() } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index fd1a67914..004d1bdd7 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -20,51 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.content.res.Resources import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf 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.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.os.ConfigurationCompat -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService +import com.vitorpamplona.amethyst.service.lang.ResultOrError import com.vitorpamplona.amethyst.service.lang.TranslationsCache -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.Locale +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.tasks.await +import kotlin.coroutines.coroutineContext @Composable fun TranslatableRichTextViewer( @@ -107,281 +84,109 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, displayText: @Composable (String) -> Unit, ) { - var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel) + val languages = accountViewModel.account.settings.syncedSettings.languages + val translateTo by languages.translateTo.collectAsStateWithLifecycle() + val dontTranslateFrom by languages.dontTranslateFrom.collectAsStateWithLifecycle() + val languagePreferences by languages.languagePreferences.collectAsStateWithLifecycle() - CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) { - RenderTextWithTranslateOptions( - translatedTextState = it, - content = content, - translationMessageModifier = translationMessageModifier, - accountViewModel = accountViewModel, - displayText = displayText, - ) + val translatedTextState = + remember(id, content, translateTo, dontTranslateFrom) { + mutableStateOf( + TranslationsCache.get(content, translateTo, dontTranslateFrom) + ?: TranslationConfig(content, null, null), + ) + } + + LaunchedEffect(content, translateTo, dontTranslateFrom) { + try { + translatedTextState.value = translateAndCache(content, translateTo, dontTranslateFrom) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Transient ML Kit / network failure — keep showing the original. Do not cache: a + // one-off failure shouldn't block future attempts on the same text. + } } + + RenderTextWithTranslateOptions( + translatedTextState = translatedTextState.value, + content = content, + languagePreferences = languagePreferences, + translationMessageModifier = translationMessageModifier, + accountViewModel = accountViewModel, + displayText = displayText, + ) } @Composable private fun RenderTextWithTranslateOptions( translatedTextState: TranslationConfig, content: String, + languagePreferences: Map, translationMessageModifier: Modifier = MaxWidthPaddingTop5dp, accountViewModel: AccountViewModel, displayText: @Composable (String) -> Unit, ) { - var showOriginal by - remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } + val source = translatedTextState.sourceLang + val target = translatedTextState.targetLang + val translationOccurred = source != null && target != null && source != target - val toBeViewed by - remember(translatedTextState) { - derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content } + val storedPreference = if (translationOccurred) languagePreferences["$source,$target"] else null + var showOriginal by + remember(translatedTextState, storedPreference) { + mutableStateOf(storedPreference == source) } + val toBeViewed = if (showOriginal || !translationOccurred) content else translatedTextState.result + Column { displayText(toBeViewed) - if ( - translatedTextState.sourceLang != null && - translatedTextState.targetLang != null && - translatedTextState.sourceLang != translatedTextState.targetLang - ) { - TranslationMessage( - translatedTextState.sourceLang, - translatedTextState.targetLang, - translationMessageModifier, - accountViewModel, - ) { - showOriginal = it - } + if (translationOccurred) { + TranslationStatusBar( + source = source, + target = target, + modifier = translationMessageModifier, + accountViewModel = accountViewModel, + ) { showOriginal = it } } } } -@Composable -private fun TranslationMessage( - source: String, - target: String, - modifier: Modifier = MaxWidthPaddingTop5dp, - accountViewModel: AccountViewModel, - onChangeWhatToShow: (Boolean) -> Unit, -) { - var langSettingsPopupExpanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Row( - modifier = modifier, - ) { - val textColor = MaterialTheme.colorScheme.lessImportantLink - - Text( - text = - buildAnnotatedString { - appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } - append(" ${stringRes(R.string.translations_translated_from)} ") - appendLink(Locale.forLanguageTag(source).displayName, textColor) { onChangeWhatToShow(true) } - append(" ${stringRes(R.string.translations_to)} ") - appendLink(Locale.forLanguageTag(target).displayName, textColor) { onChangeWhatToShow(false) } - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f), - fontSize = Font14SP, - ), - overflow = TextOverflow.Visible, - maxLines = 3, - ) - - DropdownMenu( - expanded = langSettingsPopupExpanded, - onDismissRequest = { langSettingsPopupExpanded = false }, - ) { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (source in accountViewModel.dontTranslateFrom()) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_never_translate_from_lang, - Locale.forLanguageTag(source).displayName, - ), - ) - } - }, - onClick = { - accountViewModel.toggleDontTranslateFrom(source) - langSettingsPopupExpanded = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.preferenceBetween(source, target) == source) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_show_in_lang_first, - Locale.forLanguageTag(source).displayName, - ), - ) - } - }, - onClick = { - accountViewModel.prefer(source, target, source) - langSettingsPopupExpanded = false - }, - ) - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.syncedSettings.languages - .preferenceBetween(source, target) == target - ) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_show_in_lang_first, - Locale.forLanguageTag(target).displayName, - ), - ) - } - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, target) - langSettingsPopupExpanded = false - } - }, - ) - HorizontalDivider(thickness = DividerThickness) - - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { lang -> - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.translateToContains(lang.language)) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringRes( - R.string.translations_always_translate_to_lang, - lang.displayName, - ), - ) - } - }, - onClick = { - langSettingsPopupExpanded = false - accountViewModel.updateTranslateTo(lang.language) - }, - ) - } - } - } - } -} - -@Composable -fun translateAndWatchLanguageChanges( +/** + * Returns the translation for [content] under the current language settings, hitting the cache + * first and falling back to ML Kit. ML Kit's "no translation needed" cancellation (same language, + * undetected, blocklisted) is bridged into a no-op [TranslationConfig] that is itself cached, so + * the same text scrolling back into view doesn't re-run language identification. + */ +private suspend fun translateAndCache( content: String, - id: String, - accountViewModel: AccountViewModel, -): MutableState { - val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) } + translateTo: String, + dontTranslateFrom: Set, +): TranslationConfig { + TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let { return it } - TranslateAndWatchLanguageChanges( - content, - accountViewModel, - ) { result -> - if ( - !translatedTextState.value.result.equals(result.result, true) || - translatedTextState.value.sourceLang != result.sourceLang || - translatedTextState.value.targetLang != result.targetLang - ) { - TranslationsCache.set(content, result) - translatedTextState.value = result + val noOp = TranslationConfig(content, null, null) + val raw = + try { + LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo).await() + } catch (e: CancellationException) { + // If our coroutine is the cancelled one, propagate; otherwise it's ML Kit signalling + // "no translation needed" — cache the no-op and return it. + coroutineContext.ensureActive() + return noOp.also { TranslationsCache.set(content, translateTo, dontTranslateFrom, it) } } - } + coroutineContext.ensureActive() - return translatedTextState + val config = raw.toTranslationConfig(content) ?: noOp + TranslationsCache.set(content, translateTo, dontTranslateFrom, config) + return config } -@Composable -fun TranslateAndWatchLanguageChanges( - content: String, - accountViewModel: AccountViewModel, - onTranslated: (TranslationConfig) -> Unit, -) { - LaunchedEffect(Unit) { - // This takes some time. Launches as a Composition scope to make sure this gets cancel if this - // item gets out of view. - withContext(Dispatchers.IO) { - LanguageTranslatorService - .autoTranslate( - content, - accountViewModel.dontTranslateFrom(), - accountViewModel.translateTo(), - ).addOnCompleteListener { task -> - if (task.isSuccessful && !content.equals(task.result.result, true)) { - if (task.result.sourceLang != null && task.result.targetLang != null) { - val preference = - accountViewModel.account.settings.preferenceBetween( - task.result.sourceLang!!, - task.result.targetLang!!, - ) - val newConfig = - TranslationConfig( - result = task.result.result, - sourceLang = task.result.sourceLang, - targetLang = task.result.targetLang, - showOriginal = preference == task.result.sourceLang, - ) - - onTranslated(newConfig) - } - } - } - } - } +private fun ResultOrError.toTranslationConfig(content: String): TranslationConfig? { + val translated = result ?: return null + val source = sourceLang ?: return null + val target = targetLang ?: return null + if (source == target || translated == content) return null + return TranslationConfig(translated, source, target) } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt new file mode 100644 index 000000000..880c28d97 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslationStatusBar.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.res.Resources +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.os.ConfigurationCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import java.util.Locale + +/** + * The "Auto-translated from X to Y" footer shown beneath translated rich text. Tapping the source + * or target labels toggles which version is displayed; tapping "Auto-translated" opens the + * per-language preferences dropdown. + */ +@Composable +internal fun TranslationStatusBar( + source: String, + target: String, + modifier: Modifier = MaxWidthPaddingTop5dp, + accountViewModel: AccountViewModel, + onShowOriginalChange: (Boolean) -> Unit, +) { + var dropdownExpanded by remember { mutableStateOf(false) } + + val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName } + val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName } + + Row(modifier = modifier) { + TranslationStatusText( + sourceDisplay = sourceDisplay, + targetDisplay = targetDisplay, + onAutoLabelClick = { dropdownExpanded = !dropdownExpanded }, + onSourceLabelClick = { onShowOriginalChange(true) }, + onTargetLabelClick = { onShowOriginalChange(false) }, + ) + + if (dropdownExpanded) { + LangSettingsDropdown( + source = source, + target = target, + sourceDisplay = sourceDisplay, + targetDisplay = targetDisplay, + accountViewModel = accountViewModel, + onDismiss = { dropdownExpanded = false }, + ) + } + } +} + +@Composable +private fun TranslationStatusText( + sourceDisplay: String, + targetDisplay: String, + onAutoLabelClick: () -> Unit, + onSourceLabelClick: () -> Unit, + onTargetLabelClick: () -> Unit, +) { + val textColor = MaterialTheme.colorScheme.lessImportantLink + val autoLabel = stringRes(R.string.translations_auto) + val translatedFromLabel = stringRes(R.string.translations_translated_from) + val toLabel = stringRes(R.string.translations_to) + + Text( + text = + buildAnnotatedString { + appendLink(autoLabel, textColor, onAutoLabelClick) + append(" $translatedFromLabel ") + appendLink(sourceDisplay, textColor, onSourceLabelClick) + append(" $toLabel ") + appendLink(targetDisplay, textColor, onTargetLabelClick) + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f), + fontSize = Font14SP, + ), + overflow = TextOverflow.Visible, + maxLines = 3, + ) +} + +@Composable +private fun LangSettingsDropdown( + source: String, + target: String, + sourceDisplay: String, + targetDisplay: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val deviceLocales = rememberDeviceLocales() + val settings = accountViewModel.account.settings + val preferenceForPair = settings.preferenceBetween(source, target) + + DropdownMenu(expanded = true, onDismissRequest = onDismiss) { + LangMenuItem( + checked = source in accountViewModel.dontTranslateFrom(), + label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay), + onClick = { + accountViewModel.toggleDontTranslateFrom(source) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + LangMenuItem( + checked = preferenceForPair == source, + label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay), + onClick = { + accountViewModel.prefer(source, target, source) + onDismiss() + }, + ) + LangMenuItem( + checked = preferenceForPair == target, + label = stringRes(R.string.translations_show_in_lang_first, targetDisplay), + onClick = { + accountViewModel.prefer(source, target, target) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + for (lang in deviceLocales) { + LangMenuItem( + checked = settings.translateToContains(lang.language), + label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName), + onClick = { + onDismiss() + accountViewModel.updateTranslateTo(lang.language) + }, + ) + } + } +} + +@Composable +private fun rememberDeviceLocales(): List = + remember { + val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration) + (0 until list.size()).mapNotNull { list.get(it) } + } + +@Composable +private fun LangMenuItem( + checked: Boolean, + label: String, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { CheckmarkRow(checked, label) }, + onClick = onClick, + ) +} + +@Composable +private fun CheckmarkRow( + checked: Boolean, + label: String, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (checked) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.size(10.dp)) + Text(label) + } +} diff --git a/amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt b/amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt new file mode 100644 index 000000000..a415c5241 --- /dev/null +++ b/amethyst/src/testPlay/java/com/vitorpamplona/amethyst/service/lang/TranslationDictionaryTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.lang + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class TranslationDictionaryTest { + // ----- isWorthTranslating ----- + + @Test + fun `short text is not worth translating`() { + assertFalse(TranslationDictionary.isWorthTranslating("")) + assertFalse(TranslationDictionary.isWorthTranslating("a")) + assertFalse(TranslationDictionary.isWorthTranslating("ab")) + assertFalse(TranslationDictionary.isWorthTranslating("abc")) + } + + @Test + fun `letterless text is not worth translating`() { + assertFalse(TranslationDictionary.isWorthTranslating("123456")) + assertFalse(TranslationDictionary.isWorthTranslating("!!!!!!")) + assertFalse(TranslationDictionary.isWorthTranslating(" ")) + // Emoji-only. + assertFalse(TranslationDictionary.isWorthTranslating("😊😊😊")) + } + + @Test + fun `text with at least one letter is worth translating`() { + assertTrue(TranslationDictionary.isWorthTranslating("Hello")) + assertTrue(TranslationDictionary.isWorthTranslating("a123")) + assertTrue(TranslationDictionary.isWorthTranslating("你好世界")) + // Mixed emoji + letters. + assertTrue(TranslationDictionary.isWorthTranslating("😊 hi")) + } + + // ----- placeholder ----- + + @Test + fun `placeholder is a single Unicode Private Use Area codepoint`() { + val p0 = TranslationDictionary.placeholder(0) + val p1 = TranslationDictionary.placeholder(1) + assertEquals(1, p0.codePointCount(0, p0.length)) + assertEquals(1, p1.codePointCount(0, p1.length)) + assertEquals(0xE000, p0.codePointAt(0)) + assertEquals(0xE001, p1.codePointAt(0)) + assertNotEquals(p0, p1) + } + + @Test + fun `placeholder rejects out of range index`() { + try { + TranslationDictionary.placeholder(-1) + fail("expected IllegalArgumentException for negative index") + } catch (_: IllegalArgumentException) { + // expected + } + try { + TranslationDictionary.placeholder(TranslationDictionary.PLACEHOLDER_LIMIT + 1) + fail("expected IllegalArgumentException for index past limit") + } catch (_: IllegalArgumentException) { + // expected + } + } + + // ----- build ----- + + @Test + fun `build empty dictionary for plain text`() { + val dict = TranslationDictionary.build("Just plain text with no special tokens") + assertTrue(dict.isEmpty()) + } + + @Test + fun `build picks up a single URL`() { + val text = "Have you seen this https://t.me/mygroup yet?" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + assertTrue("dict should contain the URL value", dict.containsValue("https://t.me/mygroup")) + } + + @Test + fun `build picks up nostr NIP-19 references`() { + val text = "see nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy here" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + assertTrue(dict.containsValue("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")) + } + + @Test + fun `build picks up Lightning invoices`() { + val invoice = + "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjs" + val dict = TranslationDictionary.build("Pay me: $invoice please") + assertEquals(1, dict.size) + assertTrue(dict.containsValue(invoice)) + } + + @Test + fun `build picks up legacy NIP-08 positional references`() { + val text = "Have you seen this, #[0]" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + assertTrue(dict.containsValue("#[0]")) + } + + @Test + fun `build deduplicates repeated occurrences of the same value`() { + val text = "https://a.com and again https://a.com" + val dict = TranslationDictionary.build(text) + assertEquals(1, dict.size) + } + + @Test + fun `build collects multiple distinct tokens`() { + val text = + "ln: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3 url: https://a.com " + + "ref: nostr:nevent1qqsabcdefghjklmnpqrstuvwxyz023456789 nip08: #[0]" + val dict = TranslationDictionary.build(text) + // We expect at least one entry per category. Exact count depends on the regexes' bech32-charset + // truncation behaviour; the contract we care about is that each distinct kind is captured. + assertTrue(dict.values.any { it.startsWith("lnbc") }) + assertTrue("https://a.com" in dict.values) + assertTrue(dict.values.any { it.startsWith("nostr:nevent1") }) + assertTrue("#[0]" in dict.values) + } + + @Test + fun `build rejects URLs with Chinese full-width punctuation false-positives`() { + // The URL detector greedily includes , and 。 — those substrings are not real URLs. + val text = "看 http://x.com,再见。" + val dict = TranslationDictionary.build(text) + for (value in dict.values) { + assertFalse("URL with , or 。 should be skipped: $value", value.contains(',') || value.contains('。')) + } + } + + // ----- encode / decode round-trip ----- + + @Test + fun `encode replaces dictionary values with placeholders and decode restores them`() { + val text = "Have you seen this https://t.me/mygroup ?" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + + assertFalse("URL must be removed from encoded text", encoded.contains("https://t.me/mygroup")) + assertTrue("encoded text must contain the placeholder", encoded.codePoints().anyMatch { it in 0xE000..0xF8FF }) + + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `round-trip preserves nostr references through a simulated translation`() { + val text = "Have you seen this, #[0] and nostr:nevent1qqsabcdefgh023456?" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + + // Simulate a translator: rewrite the surrounding English to Portuguese, but pass placeholders through unchanged. + val translated = encoded.replace("Have you seen this", "Você já viu isso").replace("and", "e") + + val decoded = TranslationDictionary.decode(translated, dict)!! + assertTrue("decoded must contain #[0]", decoded.contains("#[0]")) + assertTrue("decoded must contain the nostr ref", decoded.contains("nostr:nevent1qqsabcdefgh023456")) + assertFalse("decoded must not leak placeholder codepoints", decoded.codePoints().anyMatch { it in 0xE000..0xF8FF }) + } + + @Test + fun `round-trip preserves multiple URLs of differing lengths`() { + val text = + "short https://a.co and " + + "long https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg end" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `encode replaces longer values first to avoid prefix collisions`() { + // If "https://a.co" was replaced before "https://a.co/long", the longer URL would be partially clobbered. + val text = "long https://a.co/long short https://a.co end" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + // Both URLs must be fully replaced — no leftover http:// fragments. + assertFalse("no leftover URL fragment in encoded text", encoded.contains("https://")) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `decode does not corrupt user text containing the old B0 C0 A0 placeholders`() { + // Regression for the pre-rewrite bug: old placeholders "B0", "C0", "A0" collided with arbitrary + // user content. The new PUA placeholders are invisible codepoints that cannot occur in normal text, + // so a sentence mentioning "B0" or "C0" should round-trip unchanged when there's nothing to replace. + val text = "Pricing tier B0 vs C0 vs A0 — see https://docs.example.com/tiers" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict)!! + assertTrue(decoded.contains("B0")) + assertTrue(decoded.contains("C0")) + assertTrue(decoded.contains("A0")) + assertEquals(text, decoded) + } + + @Test + fun `case sensitive replacement preserves user text that differs only in case`() { + // The pre-rewrite implementation used ignoreCase=true, which could mangle user text that looked + // like a URL placeholder in a different case. With case-sensitive replacement this can't happen. + val text = "Visit HTTPS://A.COM/Path then revisit https://a.com/Path" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `encode is no-op when dictionary is empty`() { + val text = "Plain text without anything special" + assertEquals(text, TranslationDictionary.encode(text, emptyMap())) + } + + @Test + fun `decode handles null input`() { + assertNull(TranslationDictionary.decode(null, mapOf("a" to "b"))) + } + + @Test + fun `decode is no-op when dictionary is empty`() { + val text = "anything goes" + assertEquals(text, TranslationDictionary.decode(text, emptyMap())) + } + + @Test + fun `mixed content from real-world test cases round-trips`() { + val text = + "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + } + + @Test + fun `complex real-world post round-trips`() { + // Mirrors TranslationsTest#testHttp: URL + emoji + multiple NIP-19 references. + val text = + "https://m.primal.net/MdDd.png \nRunning... 😁 " + + "nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll " + + "nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm" + val dict = TranslationDictionary.build(text) + val encoded = TranslationDictionary.encode(text, dict) + val decoded = TranslationDictionary.decode(encoded, dict) + assertEquals(text, decoded) + // And every special token must have been replaced in the encoded form. + assertFalse(encoded.contains("https://m.primal.net/MdDd.png")) + assertFalse(encoded.contains("nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll")) + assertFalse(encoded.contains("nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm")) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index ab3ab5976..f8c4a21ee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -173,7 +173,7 @@ class Context( * publish from", which mirrors `User.outboxRelays()` in the * Android app. */ - fun outboxRelays(): Set = + suspend fun outboxRelays(): Set = relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() ?: DefaultNIP65RelaySet @@ -181,7 +181,7 @@ class Context( * DM inbox relays (NIP-17 kind:10050) for this account. Falls back * to [DefaultDMRelayList] when no kind:10050 has been seen. */ - fun inboxRelays(): Set = + suspend fun inboxRelays(): Set = dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet() ?: DefaultDMRelayList.toSet() @@ -190,12 +190,12 @@ class Context( * back to [outboxRelays] when no kind:10051 has been seen — same * fallback the Android app uses for KeyPackage discovery. */ - fun keyPackageRelays(): Set = + suspend fun keyPackageRelays(): Set = keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet() ?: outboxRelays() /** Union of all three buckets. */ - fun anyRelays(): Set = outboxRelays() + inboxRelays() + keyPackageRelays() + suspend fun anyRelays(): Set = outboxRelays() + inboxRelays() + keyPackageRelays() /** * Seed relays for "look up someone we know nothing about" queries — @@ -208,7 +208,7 @@ class Context( * most reliable place to find a stranger's replaceable events even when * we and they have completely disjoint relay configurations. */ - fun bootstrapRelays(): Set = + suspend fun bootstrapRelays(): Set = buildSet { addAll(anyRelays()) addAll(DefaultNIP65RelaySet) @@ -319,7 +319,7 @@ class Context( * Every event-arrival path in the CLI funnels through this method * so that [store] is the authoritative cache of what Amy has seen. */ - fun verifyAndStore(event: Event): Boolean { + suspend fun verifyAndStore(event: Event): Boolean { if (!event.verify()) { System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature") return false @@ -342,7 +342,7 @@ class Context( * this user. Callers that need a network fetch on miss should fall * back to [drain] explicitly — this helper never hits the network. */ - fun profileOf(pubKey: HexKey): MetadataEvent? = + suspend fun profileOf(pubKey: HexKey): MetadataEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(MetadataEvent.KIND), limit = 1), @@ -352,7 +352,7 @@ class Context( * Latest known kind:10002 advertised relay list (NIP-65) for * [pubKey]. `null` when Amy has never seen one. */ - fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? = + suspend fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1), @@ -363,7 +363,7 @@ class Context( * `null` if Amy has never observed one. Useful for follow-graph * lookups without re-hitting relays. */ - fun contactsOf(pubKey: HexKey): ContactListEvent? = + suspend fun contactsOf(pubKey: HexKey): ContactListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1), @@ -374,7 +374,7 @@ class Context( * for [pubKey], or `null` if Amy has never observed one. Used by * `dm send` to resolve where to deliver a wrap. */ - fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = + suspend fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1), @@ -386,7 +386,7 @@ class Context( * `marmot key-package check` and `marmot await key-package` to * locate where the recipient publishes their KeyPackages. */ - fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? = + suspend fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? = store .query( Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1), @@ -405,7 +405,7 @@ class Context( * we'll still hand back the old list. Commands that care can drain * (which re-populates the cache) or expose a `--refresh` flag. */ - fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? { + suspend fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? { val dm = dmInboxOf(pubKey) val kp = keyPackageRelaysOf(pubKey) val nip65 = relaysOf(pubKey) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index 87e0c6bfb..ba928622e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -142,7 +142,7 @@ object FeedCommand { * an arbitrary `--author` we have no idea where they publish, so we * widen to the bootstrap union. */ - private fun relaysForReadingFeed( + private suspend fun relaysForReadingFeed( ctx: Context, mode: String, ): Set = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 360e34fe9..0b827167f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -226,7 +226,7 @@ object ProfileCommands { * else's profile, fall back to the bootstrap union so we still find a * kind:0 even when our relay set and theirs are disjoint. */ - private fun relaysForReadingProfile( + private suspend fun relaysForReadingProfile( ctx: Context, isSelf: Boolean, ): Set = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 256e3b561..aade5d829 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -137,7 +137,7 @@ object RelayCommands { } } - private fun list(dataDir: DataDir): Int { + private suspend fun list(dataDir: DataDir): Int { val ctx = Context.open(dataDir) try { val self = ctx.identity.pubKeyHex diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt index 4ee0a7ee6..470c42bbf 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt @@ -132,7 +132,7 @@ object StoreCommands { return 0 } - private fun sweepExpired(dataDir: DataDir): Int = + private suspend fun sweepExpired(dataDir: DataDir): Int = withStore(dataDir) { store -> val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at") val before = countEntries(expiresAtDir) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index 54a2b1626..f38e06456 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -79,7 +79,7 @@ fun BookmarksScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val scope = rememberCoroutineScope() // Tab state diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index c24e30d2a..d84c3d167 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -280,7 +280,7 @@ fun FeedScreen( val followedUsers by localCache.followedUsers.collectAsState() // Available relay URLs — subscribe triggers connection on-demand - val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + val allRelayUrls = relayStatuses.keys // Feed relays from relay categories (NIP-65 outbox, minus blocked, with fallback) val relayCategories = LocalRelayCategories.current diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 1429565d7..319d10111 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -115,7 +115,7 @@ fun NotificationsScreen( subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val scope = rememberCoroutineScope() val notificationState = remember { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index dc783fcb1..37cb44f28 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -183,7 +183,7 @@ fun ReadsScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val scope = rememberCoroutineScope() val eventState = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 5574aceb5..094d457f9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -130,7 +130,7 @@ fun SearchScreen( val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() - val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } + val allRelayUrls = relayStatuses.keys val relayCategories = LocalRelayCategories.current val searchRelays by relayCategories.searchRelays.collectAsState() val displayText by state.displayText.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 894481dc0..d67732731 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -90,7 +90,7 @@ fun ThreadScreen( onReply: (Event) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys // Lightbox state var lightboxState by remember { mutableStateOf(null) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 81deb966a..5f8661a25 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -122,7 +122,7 @@ fun UserProfileScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys // User metadata — seed from cache so returning to profile is instant val cachedUser = remember(pubKeyHex) { localCache.getUserIfExists(pubKeyHex) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index 485d92f91..32e6e1de4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -78,7 +78,7 @@ fun NewDmDialog( val relaySearchResults by searchState.relaySearchResults.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() - val connectedRelays = remember(relayStatuses) { relayStatuses.keys } + val connectedRelays = relayStatuses.keys val focusRequester = remember { FocusRequester() } // NIP-50 relay search when local cache has few/no results diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d19112fc..1dc29b301 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,8 @@ kotlinxSerialization = "1.11.0" genaiProofreading = "1.0.0-beta1" genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" +genaiImageDescription = "1.0.0-beta1" +imageLabeling = "16.0.0" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "2.2.1" @@ -148,6 +150,8 @@ jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-t google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-proofreading", version.ref = "genaiProofreading" } google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } +google-mlkit-genai-image-description = { group = "com.google.mlkit", name = "genai-image-description", version.ref = "genaiImageDescription" } +google-mlkit-image-labeling = { group = "com.google.android.gms", name = "play-services-mlkit-image-labeling", version.ref = "imageLabeling" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 4268dcef3..5e1cf9999 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -46,7 +46,7 @@ class LiveEventStore( onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior ) - fun insert(event: Event) { + suspend fun insert(event: Event) { store.insert(event) newEventStream.tryEmit(event) } @@ -70,5 +70,5 @@ class LiveEventStore( } } - fun count(filters: List) = store.count(filters) + suspend fun count(filters: List) = store.count(filters) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 71a6fb944..dd43f03d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -111,6 +111,36 @@ class RelaySession( } } + private suspend fun handleEvent(cmd: EventCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + try { + store.insert(cmd.event) + send(OkMessage(cmd.event.id, true, "")) + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) + } + } + + private suspend fun handleCount(cmd: CountCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.queryId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val total = store.count(filters) + + send(CountMessage(cmd.queryId, CountResult(total))) + } + // -- NIP-42: AUTH --------------------------------------------------------- private fun handleAuth(cmd: AuthCmd) { val result = policy.accept(cmd) @@ -164,38 +194,6 @@ class RelaySession( } } - // -- NIP-01: EVENT -------------------------------------------------------- - private fun handleEvent(cmd: EventCmd) { - val result = policy.accept(cmd) - if (result is PolicyResult.Rejected) { - send(OkMessage(cmd.event.id, false, result.reason)) - return - } - - try { - store.insert(cmd.event) - send(OkMessage(cmd.event.id, true, "")) - } catch (e: Exception) { - send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) - } - } - - // -- NIP-45: COUNT -------------------------------------------------------- - private fun handleCount(cmd: CountCmd) { - val result = policy.accept(cmd) - if (result is PolicyResult.Rejected) { - send(ClosedMessage(cmd.queryId, result.reason)) - return - } - - // Policy may rewrite filters to match the user's access level. - val filters = (result as PolicyResult.Accepted).cmd.filters - - val total = store.count(filters) - - send(CountMessage(cmd.queryId, CountResult(total))) - } - init { policy.onConnect(::send) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index e9268922d..e70773c29 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -24,37 +24,37 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter interface IEventStore : AutoCloseable { - fun insert(event: Event) + suspend fun insert(event: Event) interface ITransaction { fun insert(event: Event) } - fun transaction(body: ITransaction.() -> Unit) + suspend fun transaction(body: ITransaction.() -> Unit) - fun query(filter: Filter): List + suspend fun query(filter: Filter): List - fun query(filters: List): List + suspend fun query(filters: List): List - fun query( + suspend fun query( filter: Filter, onEach: (T) -> Unit, ) - fun query( + suspend fun query( filters: List, onEach: (T) -> Unit, ) - fun count(filter: Filter): Int + suspend fun count(filter: Filter): Int - fun count(filters: List): Int + suspend fun count(filters: List): Int - fun delete(filter: Filter) + suspend fun delete(filter: Filter) - fun delete(filters: List) + suspend fun delete(filters: List) - fun deleteExpiredEvents() + suspend fun deleteExpiredEvents() override fun close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 0a861cc9b..7fb287555 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -34,33 +34,37 @@ class EventStore( ) : IEventStore { val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) - override fun insert(event: Event) = store.insertEvent(event) + override suspend fun insert(event: Event) = store.insertEvent(event) - override fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body) + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body) - override fun query(filter: Filter) = store.query(filter) + override suspend fun query(filter: Filter) = store.query(filter) - override fun query(filters: List) = store.query(filters) + override suspend fun query(filters: List) = store.query(filters) - override fun query( + override suspend fun query( filter: Filter, onEach: (T) -> Unit, ) = store.query(filter, onEach) - override fun query( + override suspend fun query( filters: List, onEach: (T) -> Unit, ) = store.query(filters, onEach) - override fun count(filter: Filter) = store.count(filter) + override suspend fun count(filter: Filter) = store.count(filter) - override fun count(filters: List) = store.count(filters) + override suspend fun count(filters: List) = store.count(filters) - override fun delete(filter: Filter) = store.delete(filter) + override suspend fun delete(filter: Filter) { + store.delete(filter) + } - override fun delete(filters: List) = store.delete(filters) + override suspend fun delete(filters: List) { + store.delete(filters) + } - override fun deleteExpiredEvents() = store.deleteExpiredEvents() + override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents() - override fun close() = store.connection.close() + override fun close() = store.close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt index 4b2d6bf27..841abbea8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteConnection -fun SQLiteEventStore.explainQuery( +suspend fun SQLiteEventStore.explainQuery( sql: String, args: Array = emptyArray(), -) = connection.explainQuery(sql, args.map { it.toString() }.toTypedArray()) +): String = pool.useReader { it.explainQuery(sql, args.map { a -> a.toString() }.toTypedArray()) } fun SQLiteConnection.explainQuery( sql: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md index 3c3d5c610..afac612fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md @@ -80,10 +80,39 @@ store.query( ) ``` +## Concurrency + +`androidx.sqlite.SQLiteConnection` is not thread-safe — same contract as +`sqlite3*` in the C API. To support concurrent inserts and reads from +multiple coroutines, `SQLiteEventStore` owns a Room-style +[`SQLiteConnectionPool`](SQLiteConnectionPool.kt): + +- **One writer connection**, guarded by a coroutine `Mutex`. SQLite only + allows one writer at the file level anyway, so serialising here costs + nothing — it just queues callers cooperatively instead of crashing + them on `BEGIN IMMEDIATE`. +- **N reader connections** (default 4), handed out from a `Channel` that + doubles as a semaphore. Under WAL (`PRAGMA journal_mode = WAL`) + readers run in parallel with the writer and with each other. + +For in-memory databases (`dbName == null`) the pool degrades to a +single shared connection — every fresh `:memory:` connection would +otherwise be a *separate* DB. Writes still serialise correctly; reads +just take the same writer mutex. + +The whole public API on `EventStore` / `SQLiteEventStore` is therefore +`suspend`. Callers must be in a coroutine; on Android, schedule +maintenance work as a `CoroutineWorker`. + +`Mutex` is non-reentrant: do not call `eventStore.query(...)` from +inside a `transaction { ... }` body. The transaction body itself +already holds the writer connection — query against the +`SQLiteConnection` handed to your block instead. + ## How to Use The `EventStore` class provides a high-level interface for interacting with the event database. -It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning. +It owns the underlying [`SQLiteConnectionPool`](SQLiteConnectionPool.kt) and the query planner. ### Initialization @@ -95,7 +124,7 @@ val eventStore = EventStore("dbname.db", relayUrlIdentifier) ### Querying Events -To query events, use the `query` method with one or more `Filter` objects: +To query events, use the `query` method with one or more `Filter` objects (in a coroutine): ```kotlin val filters = listOf( @@ -129,6 +158,18 @@ Insert a single event using the `insert` method: eventStore.insert(event) ``` +For batch inserts, prefer a single `transaction` — one `BEGIN`/`COMMIT` +per batch is roughly an order of magnitude faster on WAL than one per +event: + +```kotlin +eventStore.transaction { + insert(event1) + insert(event2) + insert(event3) +} +``` + ### Deleting Events Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually @@ -154,11 +195,13 @@ The store exposes a `deleteExpiredEvents` to be used in a periodic clean up proc should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We recommend a 15-minute window to remove recently expired events from the database. -Here's an example of a Worker that should be added to your application class. +Here's an example of a Worker that should be added to your application class. Use +`CoroutineWorker` (not `Worker`) — `deleteExpiredEvents()` is a `suspend` function. ```kotlin -class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { - override fun doWork(): Result { +class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { YourApplication.store.deleteExpiredEvents() return Result.success() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt new file mode 100644 index 000000000..5ca19d985 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip01Core.store.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Room-style connection pool for an `androidx.sqlite` database. + * + * `androidx.sqlite.SQLiteConnection` is not thread-safe (same contract as + * `sqlite3*` in the C API): a single connection may only be used by one + * thread at a time. Two coroutines hitting the same connection in parallel + * race on `BEGIN IMMEDIATE` and prepared-statement state, which surfaces + * as `SQLITE_ERROR: cannot start a transaction within a transaction` or + * `SQLITE_MISUSE`. + * + * The pool mirrors what Room does: + * + * - **One writer connection**, guarded by a coroutine [Mutex]. SQLite + * only allows a single writer at the file level anyway, so serialising + * writes here costs nothing — it just queues callers cooperatively + * instead of crashing them. + * - **N reader connections**, handed out from a [Channel] that doubles + * as a semaphore. Under WAL (`PRAGMA journal_mode = WAL`) readers run + * in parallel with the writer and with each other. + * + * For in-memory databases (`dbName == null`) every fresh `:memory:` + * connection opens a *separate* database, so the pool degrades to a + * single-connection mode where readers also acquire the writer mutex. + * That still fixes the parallel-insert crash; it just sacrifices reader + * concurrency for an in-memory store. + * + * Lifecycle: + * 1. `init` opens the writer, runs [onConfigure] on it, then [onMigrate] + * so schema exists before any reader sees the file. + * 2. Readers are opened next and each gets [onConfigure] (PRAGMAs are + * per-connection in SQLite — `journal_mode=WAL` is the only + * database-wide one; subsequent connections inherit it). + * 3. [close] drains the reader channel and closes every connection. + * + * Reentrancy: [Mutex] is **not** reentrant — calling [useWriter] (or, on + * an in-memory DB, [useReader]) from inside an already-acquired + * [useWriter] block deadlocks. Module logic that runs under [useWriter] + * (e.g. `innerInsertEvent`) must operate on the `SQLiteConnection` + * handed to its block; it must not re-enter the pool. + */ +class SQLiteConnectionPool( + val driver: SQLiteDriver, + val dbName: String?, + val numReaders: Int = 4, + val onConfigure: (SQLiteConnection) -> Unit = {}, + val onMigrate: (SQLiteConnection) -> Unit = {}, +) : AutoCloseable { + private val isInMemory = dbName == null + + private val writerMutex = Mutex() + val writer: SQLiteConnection + + private val readers: List + private val readerChannel: Channel? + + init { + writer = openConnection() + onMigrate(writer) + + if (isInMemory) { + readers = emptyList() + readerChannel = null + } else { + readers = List(numReaders) { openConnection() } + readerChannel = Channel(numReaders) + readers.forEach { readerChannel.trySend(it) } + } + } + + private fun openConnection(): SQLiteConnection { + val db = driver.open(dbName ?: ":memory:") + onConfigure(db) + return db + } + + /** + * Acquire the writer connection for the duration of [block]. Other + * writers (and, in the in-memory single-connection mode, readers) + * suspend until the lock is released. Cancellation-aware via the + * coroutine [Mutex]. + */ + suspend fun useWriter(block: (SQLiteConnection) -> T): T = + writerMutex.withLock { + block(writer) + } + + /** + * Acquire any free reader connection for [block]. With a file-backed + * DB up to [numReaders] readers run in parallel with the writer + * (WAL). With an in-memory DB this falls back to the writer mutex + * because each `:memory:` connection would be a separate database. + */ + suspend fun useReader(block: (SQLiteConnection) -> T): T { + val ch = + readerChannel + ?: return writerMutex.withLock { block(writer) } + val conn = ch.receive() + try { + return block(conn) + } finally { + // Capacity == numReaders and we own the conn we received, so + // trySend never fails unless the channel was closed mid-flight + // (in which case the connection is being torn down anyway). + ch.trySend(conn) + } + } + + override fun close() { + readerChannel?.close() + readers.forEach { runCatching { it.close() } } + runCatching { writer.close() } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 60da85fe9..2da5cf49c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -34,24 +34,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.EventFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext class SQLiteEventStore( val driver: SQLiteDriver = BundledSQLiteDriver(), val dbName: String? = "events.db", val relay: NormalizedRelayUrl? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val numReaders: Int = 4, ) { companion object { const val DATABASE_VERSION = 2 } - val connection: SQLiteConnection by lazy { - openAndConfigure() - } - val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() @@ -89,37 +83,44 @@ class SQLiteEventStore( fullTextSearchModule, ) - private fun openAndConfigure(): SQLiteConnection { - val db = driver.open(dbName ?: ":memory:") + val pool: SQLiteConnectionPool by lazy { + SQLiteConnectionPool( + driver = driver, + dbName = dbName, + numReaders = numReaders, + onConfigure = { db -> + // 32MB memory cache (per-connection). + db.execSQL("PRAGMA cache_size=-32000;") - // 32MB memory cache - db.execSQL("PRAGMA cache_size=-32000;") + // Make sure the FKs are sane (per-connection). + db.execSQL("PRAGMA foreign_keys = ON;") - // makes sure the FKs are sane - db.execSQL("PRAGMA foreign_keys = ON;") + // SQLite implements mutations by appending them to a log, + // which it occasionally compacts into the database. This + // is called Write-Ahead Logging (WAL). Setting it on the + // first connection is enough — `journal_mode` is + // database-wide; subsequent connections inherit it. + db.execSQL("PRAGMA journal_mode = WAL;") - // SQLite implements mutations by appending them to a log, which it occasionally - // compacts into the database. This is called Write-Ahead Logging (WAL) - db.execSQL("PRAGMA journal_mode = WAL;") - - // The DB can be corrupted if the OS is shutdown before sync, which generally - // doesn't happen on Android - db.execSQL("PRAGMA synchronous = OFF;") - - val currentVersion = getUserVersion(db) - if (currentVersion == 0) { - db.transaction { - onCreate(this) - setUserVersion(this, DATABASE_VERSION) - } - } else if (currentVersion < DATABASE_VERSION) { - db.transaction { - onUpgrade(this, currentVersion, DATABASE_VERSION) - setUserVersion(this, DATABASE_VERSION) - } - } - - return db + // The DB can be corrupted if the OS shuts down before + // sync, which generally doesn't happen on Android. + db.execSQL("PRAGMA synchronous = OFF;") + }, + onMigrate = { db -> + val currentVersion = getUserVersion(db) + if (currentVersion == 0) { + db.transaction { + onCreate(this) + setUserVersion(this, DATABASE_VERSION) + } + } else if (currentVersion < DATABASE_VERSION) { + db.transaction { + onUpgrade(this, currentVersion, DATABASE_VERSION) + setUserVersion(this, DATABASE_VERSION) + } + } + }, + ) } private fun getUserVersion(db: SQLiteConnection): Int = @@ -159,25 +160,24 @@ class SQLiteEventStore( } } - fun clearDB() { - modules.reversed().forEach { it.deleteAll(connection) } - } - - suspend fun vacuum() { - // VACUUM: Rebuilds the database file, reclaiming unused space - // and reducing fragmentation. - withContext(Dispatchers.IO) { - connection.execSQL("VACUUM") + suspend fun clearDB() = + pool.useWriter { db -> + modules.reversed().forEach { it.deleteAll(db) } } - } - suspend fun analyse() { - // ANALYZE: Collects statistics about tables and indices - // to help the query planner optimize queries. - withContext(Dispatchers.IO) { - connection.execSQL("ANALYZE") + suspend fun vacuum() = + pool.useWriter { db -> + // VACUUM: Rebuilds the database file, reclaiming unused space + // and reducing fragmentation. + db.execSQL("VACUUM") + } + + suspend fun analyse() = + pool.useWriter { db -> + // ANALYZE: Collects statistics about tables and indices + // to help the query planner optimize queries. + db.execSQL("ANALYZE") } - } private fun innerInsertEvent( event: Event, @@ -190,12 +190,14 @@ class SQLiteEventStore( rightToVanishModule.insert(event, relay, headerId, db) } - fun insertEvent(event: Event) { + suspend fun insertEvent(event: Event) { if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") if (event.kind.isEphemeral()) return - connection.transaction { - innerInsertEvent(event, this) + pool.useWriter { db -> + db.transaction { + innerInsertEvent(event, this) + } } } @@ -210,64 +212,65 @@ class SQLiteEventStore( } } - fun transaction(body: Transaction.() -> Unit) { - connection.transaction { - with(Transaction(this)) { - body() + suspend fun transaction(body: Transaction.() -> Unit) { + pool.useWriter { db -> + db.transaction { + with(Transaction(this)) { + body() + } } } } - fun query(filter: Filter): List = queryBuilder.query(filter, connection) + suspend fun query(filter: Filter): List = pool.useReader { queryBuilder.query(filter, it) } - fun query(filters: List): List = queryBuilder.query(filters, connection) + suspend fun query(filters: List): List = pool.useReader { queryBuilder.query(filters, it) } - fun query( + suspend fun query( filter: Filter, onEach: (T) -> Unit, - ) = queryBuilder.query(filter, connection, onEach) + ) = pool.useReader { queryBuilder.query(filter, it, onEach) } - fun query( + suspend fun query( filters: List, onEach: (T) -> Unit, - ) = queryBuilder.query(filters, connection, onEach) + ) = pool.useReader { queryBuilder.query(filters, it, onEach) } - fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, connection) + suspend fun rawQuery(filter: Filter): List = pool.useReader { queryBuilder.rawQuery(filter, it) } - fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, connection) + suspend fun rawQuery(filters: List): List = pool.useReader { queryBuilder.rawQuery(filters, it) } - fun rawQuery( + suspend fun rawQuery( filter: Filter, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filter, connection, onEach) + ) = pool.useReader { queryBuilder.rawQuery(filter, it, onEach) } - fun rawQuery( + suspend fun rawQuery( filters: List, onEach: (RawEvent) -> Unit, - ) = queryBuilder.rawQuery(filters, connection, onEach) + ) = pool.useReader { queryBuilder.rawQuery(filters, it, onEach) } - fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(connection), connection) + suspend fun planQuery(filter: Filter) = pool.useReader { queryBuilder.planQuery(filter, seedModule.hasher(it), it) } - fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(connection), connection) + suspend fun planQuery(filters: List) = pool.useReader { queryBuilder.planQuery(filters, seedModule.hasher(it), it) } - fun count(filter: Filter): Int = queryBuilder.count(filter, connection) + suspend fun count(filter: Filter): Int = pool.useReader { queryBuilder.count(filter, it) } - fun count(filters: List): Int = queryBuilder.count(filters, connection) + suspend fun count(filters: List): Int = pool.useReader { queryBuilder.count(filters, it) } - fun delete(filter: Filter) { - queryBuilder.delete(filter, connection) - } + suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) } - fun delete(filters: List) { - queryBuilder.delete(filters, connection) - } + suspend fun delete(filters: List) = pool.useWriter { queryBuilder.delete(filters, it) } - fun delete(id: HexKey): Int { - connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) - return connection.changes() - } + suspend fun delete(id: HexKey): Int = + pool.useWriter { db -> + db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) + db.changes() + } - fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection) + suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) } + + fun close() = pool.close() } class RawEvent( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt index 1475b91e7..d7fa94ce5 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import kotlin.test.assertEquals -fun EventStore.assertQuery( +suspend fun EventStore.assertQuery( expected: T?, filter: Filter, ) { @@ -40,7 +40,7 @@ fun EventStore.assertQuery( } } -fun EventStore.assertQuery( +suspend fun EventStore.assertQuery( expected: List, filter: Filter, ) { @@ -53,7 +53,7 @@ fun EventStore.assertQuery( } } -fun SQLiteEventStore.assertQuery( +suspend fun SQLiteEventStore.assertQuery( expected: T?, filter: Filter, ) { @@ -69,7 +69,7 @@ fun SQLiteEventStore.assertQuery( } } -fun SQLiteEventStore.assertQuery( +suspend fun SQLiteEventStore.assertQuery( expected: List, filter: Filter, ) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index e3cd10d98..6c695ff74 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -307,10 +307,14 @@ class BasicTest : BaseDBTest() { // modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule // left dummy_fts3/4/5 tables behind on first probe, so the // second create() would throw "already exists". - db.store.modules - .reversed() - .forEach { it.drop(db.store.connection) } - db.store.modules.forEach { it.create(db.store.connection) } + // Drive the module re-create against the writer connection + // (drop + create touches schema, so we need exclusive access). + db.store.pool.useWriter { conn -> + db.store.modules + .reversed() + .forEach { it.drop(conn) } + db.store.modules.forEach { it.create(conn) } + } // After re-creation the store is still usable. val note = signer.sign(TextNoteEvent.build("test1")) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 51cd4f263..5e7c1b686 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.runBlocking import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -56,24 +57,26 @@ class LargeDBTests { } @Test - fun insertHeavyEvent() { - events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> - try { - db.insert(event) - } catch (e: SQLiteException) { - Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + fun insertHeavyEvent() = + runBlocking { + events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + } } } - } @Test - fun insertDatabase() { - events.forEach { event -> - try { - db.insert(event) - } catch (e: SQLiteException) { - Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + fun insertDatabase() = + runBlocking { + events.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } + } } } - } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index fa3bed86b..32621025e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -37,9 +37,9 @@ class QueryAssemblerTest : BaseDBTest() { val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.connection) + suspend fun EventStore.explain(f: Filter) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) } - fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.connection) + suspend fun EventStore.explain(f: List) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) } @Test fun testEmpty() = diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 7bd8ac2ba..8da5d14b5 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -99,7 +99,7 @@ open class FsEventStore( // Insert // ------------------------------------------------------------------ - override fun insert(event: Event) = + override suspend fun insert(event: Event) = lockManager.withWriteLock { insertLocked(event) } @@ -263,7 +263,7 @@ open class FsEventStore( } } - override fun transaction(body: IEventStore.ITransaction.() -> Unit) = + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = lockManager.withWriteLock { val txn = object : IEventStore.ITransaction { @@ -277,13 +277,13 @@ open class FsEventStore( // ------------------------------------------------------------------ @Suppress("UNCHECKED_CAST") - override fun query(filter: Filter): List { + override suspend fun query(filter: Filter): List { val out = mutableListOf() query(filter) { out.add(it) } return out } - override fun query(filters: List): List { + override suspend fun query(filters: List): List { val seen = HashSet() val out = mutableListOf() filters.forEach { f -> @@ -292,7 +292,7 @@ open class FsEventStore( return out } - override fun query( + override suspend fun query( filter: Filter, onEach: (T) -> Unit, ) { @@ -311,7 +311,7 @@ open class FsEventStore( } } - override fun query( + override suspend fun query( filters: List, onEach: (T) -> Unit, ) { @@ -321,13 +321,13 @@ open class FsEventStore( } } - override fun count(filter: Filter): Int { + override suspend fun count(filter: Filter): Int { var n = 0 query(filter) { n++ } return n } - override fun count(filters: List): Int { + override suspend fun count(filters: List): Int { var n = 0 query(filters) { n++ } return n @@ -343,7 +343,7 @@ open class FsEventStore( * entire store. This is asymmetric with `query(Filter())` which * intentionally returns every event — same contract as `SQLiteEventStore`. */ - override fun delete(filter: Filter) = + override suspend fun delete(filter: Filter) = lockManager.withWriteLock { if (filter.isEmpty()) return@withWriteLock val ids = ArrayList() @@ -352,7 +352,7 @@ open class FsEventStore( } /** See [delete] for the empty-filter contract. */ - override fun delete(filters: List) = + override suspend fun delete(filters: List) = lockManager.withWriteLock { val nonEmpty = filters.filterNot { it.isEmpty() } if (nonEmpty.isEmpty()) return@withWriteLock @@ -362,7 +362,7 @@ open class FsEventStore( } /** Delete an event by id. Returns 1 if a file was removed, 0 otherwise. */ - fun delete(id: HexKey): Int = + suspend fun delete(id: HexKey): Int = lockManager.withWriteLock { deleteLocked(id) } @@ -408,7 +408,10 @@ open class FsEventStore( if (parsed.first < event.createdAt) toDelete.add(parsed.second) } } - toDelete.forEach { delete(it) } + // Already inside the writer lock (insertLocked → processVanish); + // call the locked variant to avoid trying to re-suspend on the + // public `delete(id)` from a non-suspend body. + toDelete.forEach { deleteLocked(it) } } /** @@ -416,7 +419,7 @@ open class FsEventStore( * filenames, and deletes any entry whose `exp < now`. Matches SQLite's * `expiration < unixepoch()` predicate (note: strict `<`, not `<=`). */ - override fun deleteExpiredEvents() = + override suspend fun deleteExpiredEvents() = lockManager.withWriteLock { if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock val now = now() diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt index 8817fe731..6601c63db 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLockManager.kt @@ -67,7 +67,7 @@ internal class FsLockManager( } } - fun withWriteLock(body: () -> T): T { + fun acquireWriteLock() { inProcessLock.lock() try { // Only the outermost re-entry actually touches the file lock. @@ -83,18 +83,36 @@ internal class FsLockManager( channel = ch fileLock = l } - try { - return body() - } finally { - if (inProcessLock.holdCount == 1) { - releaseFileLock() - } + } catch (t: Throwable) { + inProcessLock.unlock() + throw t + } + } + + fun releaseWriteLock() { + try { + if (inProcessLock.holdCount == 1) { + releaseFileLock() } } finally { inProcessLock.unlock() } } + /** + * Inline so callers may invoke `suspend` functions inside the lock + * body — needed by [FsEventStore.delete], which calls the suspend + * `query` to enumerate ids before deleting them. + */ + inline fun withWriteLock(body: () -> T): T { + acquireWriteLock() + try { + return body() + } finally { + releaseWriteLock() + } + } + override fun close() { inProcessLock.lock() try { diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md index 15515c1cf..7715638b8 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md @@ -128,6 +128,11 @@ lock — atomic-rename writes mean readers see either the pre- or post-mutation state, and `NoSuchFileException` on a just-unlinked candidate is silently skipped. +The `IEventStore` API is `suspend`. The flock manager itself is +synchronous (`ReentrantLock` + `FileChannel.lock`); each suspend +public method just brackets its work in `lockManager.withWriteLock { +... }`, which is `inline` so suspend bodies pass through. + ## Usage ### Initialisation @@ -301,7 +306,7 @@ Tests live under | `FsExpirationTest` | NIP-40 future / past / equal-now / sweep / non-positive | | `FsVanishTest` | NIP-62 cascade / block / strongest-cutoff-wins / per-relay scoping | | `FsSearchTest` | tokenizer behaviour, single-token / AND-of-tokens, ordering, reopen | -| `FsMaintenanceTest` | flock, transaction commit + propagated exceptions, re-entrant lock, scrub, compact, two-thread concurrency | +| `FsMaintenanceTest` | flock, transaction commit + propagated exceptions, re-entrant lock, scrub, compact, concurrent inserts from multiple coroutines on `Dispatchers.IO` | | `FsParityTest` | drive both this store and SQLite with identical streams and assert results match | ```bash diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt index 88f40c252..2ac46adda 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -86,227 +87,239 @@ class FsDeletionTest { // ------------------------------------------------------------------ @Test - fun `kind-5 cascade-deletes a target by id`() { - val n1 = note("one", 10) - val n2 = note("two", 20) - store.insert(n1) - store.insert(n2) + fun `kind-5 cascade-deletes a target by id`() = + runBlocking { + val n1 = note("one", 10) + val n2 = note("two", 20) + store.insert(n1) + store.insert(n2) - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) + store.insert(del) - assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) - assertEquals(listOf(n2.id), store.query(Filter(ids = listOf(n2.id))).map { it.id }) - assertEquals(listOf(del.id), store.query(Filter(ids = listOf(del.id))).map { it.id }) - } + assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) + assertEquals(listOf(n2.id), store.query(Filter(ids = listOf(n2.id))).map { it.id }) + assertEquals(listOf(del.id), store.query(Filter(ids = listOf(del.id))).map { it.id }) + } @Test - fun `deletion blocks re-insertion of the same id`() { - val n1 = note("one", 10) - store.insert(n1) + fun `deletion blocks re-insertion of the same id`() = + runBlocking { + val n1 = note("one", 10) + store.insert(n1) - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) + store.insert(del) - store.insert(n1) // should be blocked - assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) - } + store.insert(n1) // should be blocked + assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) + } @Test - fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() { - // other signer authors a note - val theirs = note("not yours", 10, signer = otherSigner) - store.insert(theirs) + fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() = + runBlocking { + // other signer authors a note + val theirs = note("not yours", 10, signer = otherSigner) + store.insert(theirs) - // Our signer attempts to delete it. - val del = signer.sign(DeletionEvent.build(listOf(theirs), createdAt = 30)) - store.insert(del) + // Our signer attempts to delete it. + val del = signer.sign(DeletionEvent.build(listOf(theirs), createdAt = 30)) + store.insert(del) - // Cascade did NOT run — not our author. - assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) + // Cascade did NOT run — not our author. + assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) - // The id tombstone *is* installed (so it can fire if and when a - // future event with that id is owned by the deletion's author), - // but when the legitimate owner deletes the local copy and the - // event re-arrives from another relay, the tombstone must NOT - // block it — only same-author deletions can block re-insertion. - // Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`. - store.delete(theirs.id) - store.insert(theirs) - assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) - } + // The id tombstone *is* installed (so it can fire if and when a + // future event with that id is owned by the deletion's author), + // but when the legitimate owner deletes the local copy and the + // event re-arrives from another relay, the tombstone must NOT + // block it — only same-author deletions can block re-insertion. + // Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`. + store.delete(theirs.id) + store.insert(theirs) + assertEquals(listOf(theirs.id), store.query(Filter(ids = listOf(theirs.id))).map { it.id }) + } // ------------------------------------------------------------------ // Delete by address (addressable) // ------------------------------------------------------------------ @Test - fun `kind-5 by address cascades addressable slot`() { - val v1 = article("intro", "draft 1", 10) - val v2 = article("intro", "draft 2", 20) - store.insert(v1) - store.insert(v2) + fun `kind-5 by address cascades addressable slot`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + val v2 = article("intro", "draft 2", 20) + store.insert(v1) + store.insert(v2) - val del = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 30)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 30)) + store.insert(del) - // Slot cleared, canonical removed, indexes gone. - val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertFalse(slot.exists(), "addressable slot should be cleared") - assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id }) - } + // Slot cleared, canonical removed, indexes gone. + val dHash = FsLayout.sha256Hex("intro") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertFalse(slot.exists(), "addressable slot should be cleared") + assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id }) + } @Test - fun `newer event at a deleted address may pass the cutoff`() { - val v1 = article("intro", "draft 1", 10) - store.insert(v1) + fun `newer event at a deleted address may pass the cutoff`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + store.insert(v1) - val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + store.insert(del) - // A newer addressable at the same address should still be accepted. - val v3 = article("intro", "draft 3", 30) - store.insert(v3) + // A newer addressable at the same address should still be accepted. + val v3 = article("intro", "draft 3", 30) + store.insert(v3) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - assertEquals(listOf(v3.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertEquals(listOf(v3.id), got.map { it.id }) + } @Test - fun `older event at a deleted address is blocked by cutoff`() { - val v1 = article("intro", "draft 1", 10) - store.insert(v1) + fun `older event at a deleted address is blocked by cutoff`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + store.insert(v1) - val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + store.insert(del) - // Attempting to re-insert an event authored earlier than the deletion should fail. - val older = article("intro", "even-older", 5) - store.insert(older) - assertEquals(emptyList(), store.query(Filter(ids = listOf(older.id))).map { it.id }) - } + // Attempting to re-insert an event authored earlier than the deletion should fail. + val older = article("intro", "even-older", 5) + store.insert(older) + assertEquals(emptyList(), store.query(Filter(ids = listOf(older.id))).map { it.id }) + } @Test - fun `equal-timestamp event at a deleted address is blocked`() { - val v = article("intro", "v", 10) - store.insert(v) + fun `equal-timestamp event at a deleted address is blocked`() = + runBlocking { + val v = article("intro", "v", 10) + store.insert(v) - val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 15)) - store.insert(del) + val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 15)) + store.insert(del) - val equal = article("intro", "equal", 15) - store.insert(equal) - assertEquals(emptyList(), store.query(Filter(ids = listOf(equal.id))).map { it.id }) - } + val equal = article("intro", "equal", 15) + store.insert(equal) + assertEquals(emptyList(), store.query(Filter(ids = listOf(equal.id))).map { it.id }) + } // ------------------------------------------------------------------ // Multiple deletions: strongest cutoff wins // ------------------------------------------------------------------ @Test - fun `later kind-5 raises the address cutoff`() { - val v = article("intro", "v", 10) - store.insert(v) + fun `later kind-5 raises the address cutoff`() = + runBlocking { + val v = article("intro", "v", 10) + store.insert(v) - val del1 = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) - store.insert(del1) + val del1 = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + store.insert(del1) - val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target - store.insert(del2Target) - val del2 = signer.sign(DeletionEvent.build(listOf(del2Target), createdAt = 40)) - store.insert(del2) + val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target + store.insert(del2Target) + val del2 = signer.sign(DeletionEvent.build(listOf(del2Target), createdAt = 40)) + store.insert(del2) - // Cutoff should now be 40, so an event at createdAt=35 is blocked. - val mid = article("intro", "mid", 35) - store.insert(mid) - assertEquals(emptyList(), store.query(Filter(ids = listOf(mid.id))).map { it.id }) - } + // Cutoff should now be 40, so an event at createdAt=35 is blocked. + val mid = article("intro", "mid", 35) + store.insert(mid) + assertEquals(emptyList(), store.query(Filter(ids = listOf(mid.id))).map { it.id }) + } @Test - fun `earlier kind-5 does not lower an existing stronger cutoff`() { - val v1 = article("slug", "v1", 10) - val v2 = article("slug", "v2", 20) - store.insert(v1) - store.insert(v2) + fun `earlier kind-5 does not lower an existing stronger cutoff`() = + runBlocking { + val v1 = article("slug", "v1", 10) + val v2 = article("slug", "v2", 20) + store.insert(v1) + store.insert(v2) - val strongDel = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 100)) - store.insert(strongDel) + val strongDel = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 100)) + store.insert(strongDel) - // Now insert a weaker (earlier) deletion for the same address. - val weakTarget = article("slug", "target-for-weak", 30) - store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to - // construct a DeletionEvent that targets the slug address directly. The simplest way: - val weakDel = - signer.sign( - DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50), - ) - store.insert(weakDel) + // Now insert a weaker (earlier) deletion for the same address. + val weakTarget = article("slug", "target-for-weak", 30) + store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to + // construct a DeletionEvent that targets the slug address directly. The simplest way: + val weakDel = + signer.sign( + DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50), + ) + store.insert(weakDel) - // Cutoff should still be 100 — an event at 60 must still be blocked. - val blocked = article("slug", "should-be-blocked", 60) - store.insert(blocked) - assertEquals(emptyList(), store.query(Filter(ids = listOf(blocked.id))).map { it.id }) - } + // Cutoff should still be 100 — an event at 60 must still be blocked. + val blocked = article("slug", "should-be-blocked", 60) + store.insert(blocked) + assertEquals(emptyList(), store.query(Filter(ids = listOf(blocked.id))).map { it.id }) + } // ------------------------------------------------------------------ // Deletion event itself remains queryable // ------------------------------------------------------------------ @Test - fun `deletion event itself is indexed and queryable`() { - val n = note("x", 10) - store.insert(n) - val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) - store.insert(del) + fun `deletion event itself is indexed and queryable`() = + runBlocking { + val n = note("x", 10) + store.insert(n) + val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) + store.insert(del) - val byKind = store.query(Filter(kinds = listOf(DeletionEvent.KIND))) - assertEquals(listOf(del.id), byKind.map { it.id }) - } + val byKind = store.query(Filter(kinds = listOf(DeletionEvent.KIND))) + assertEquals(listOf(del.id), byKind.map { it.id }) + } // ------------------------------------------------------------------ // Tombstone files use hardlinks to the kind-5 canonical // ------------------------------------------------------------------ @Test - fun `non-author address deletion does not block legitimate addressable inserts`() { - // `otherSigner` (call them Bob) authors an addressable; the - // default `signer` (a stranger relative to Bob) then publishes a - // kind-5 with an `a` tag pointing at Bob's address. NIP-09 says - // only the address owner may delete it, so the stranger's event - // must NOT install an address tombstone — otherwise Bob couldn't - // publish a new version at the same address. Matches SQLite's - // `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard. - val v1 = otherArticle("shared", "v1", 10) - store.insert(v1) - assertEquals(listOf(v1.id), store.query(Filter(ids = listOf(v1.id))).map { it.id }) + fun `non-author address deletion does not block legitimate addressable inserts`() = + runBlocking { + // `otherSigner` (call them Bob) authors an addressable; the + // default `signer` (a stranger relative to Bob) then publishes a + // kind-5 with an `a` tag pointing at Bob's address. NIP-09 says + // only the address owner may delete it, so the stranger's event + // must NOT install an address tombstone — otherwise Bob couldn't + // publish a new version at the same address. Matches SQLite's + // `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard. + val v1 = otherArticle("shared", "v1", 10) + store.insert(v1) + assertEquals(listOf(v1.id), store.query(Filter(ids = listOf(v1.id))).map { it.id }) - val strangerDel = - signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) - store.insert(strangerDel) + val strangerDel = + signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + store.insert(strangerDel) - // Bob can still publish a newer version at the same address. Since - // the stranger's deletion was non-authoritative, no addr tombstone - // exists to block. - val v2 = otherArticle("shared", "v2", 30) - store.insert(v2) - assertEquals(listOf(v2.id), store.query(Filter(ids = listOf(v2.id))).map { it.id }) - } + // Bob can still publish a newer version at the same address. Since + // the stranger's deletion was non-authoritative, no addr tombstone + // exists to block. + val v2 = otherArticle("shared", "v2", 30) + store.insert(v2) + assertEquals(listOf(v2.id), store.query(Filter(ids = listOf(v2.id))).map { it.id }) + } @Test - fun `id tombstone is a hardlink to the kind-5 event`() { - val n = note("x", 10) - store.insert(n) - val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) - store.insert(del) + fun `id tombstone is a hardlink to the kind-5 event`() = + runBlocking { + val n = note("x", 10) + store.insert(n) + val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) + store.insert(del) - val tomb = root.resolve("tombstones/id/${n.id}.json") - assertTrue(tomb.exists()) - val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json") - assertEquals( - Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), - Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), - "tombstone and kind-5 canonical should share an inode", - ) - } + val tomb = root.resolve("tombstones/id/${n.id}.json") + assertTrue(tomb.exists()) + val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json") + assertEquals( + Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), + Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), + "tombstone and kind-5 canonical should share an inode", + ) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt index 946123faf..0d9d15cf0 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -63,141 +64,152 @@ class FsEventStoreTest { } @Test - fun `insert and query by id round-trips`() { - val note = signer.sign(TextNoteEvent.build("hello")) + fun `insert and query by id round-trips`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("hello")) - store.insert(note) + store.insert(note) - val got = store.query(Filter(ids = listOf(note.id))) - assertEquals(1, got.size) - assertEquals(note.id, got[0].id) - assertEquals(note.content, got[0].content) - assertEquals(note.sig, got[0].sig) - } - - @Test - fun `canonical path uses 2-char sharding`() { - val note = signer.sign(TextNoteEvent.build("shard me")) - store.insert(note) - - val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4)) - val file = shard.resolve("${note.id}.json") - assertTrue(file.exists(), "expected canonical at $file") - } - - @Test - fun `query returns empty when nothing inserted`() { - val note = signer.sign(TextNoteEvent.build("missing")) - assertEquals(emptyList(), store.query(Filter(ids = listOf(note.id)))) - } - - @Test - fun `delete by id removes the file`() { - val note = signer.sign(TextNoteEvent.build("to-delete")) - store.insert(note) - assertEquals(1, store.count(Filter(ids = listOf(note.id)))) - - val removed = store.delete(note.id) - assertEquals(1, removed) - assertEquals(0, store.count(Filter(ids = listOf(note.id)))) - } - - @Test - fun `delete returns 0 when event absent`() { - val note = signer.sign(TextNoteEvent.build("never-inserted")) - assertEquals(0, store.delete(note.id)) - } - - @Test - fun `delete by filter with ids removes matching events`() { - val a = signer.sign(TextNoteEvent.build("a")) - val b = signer.sign(TextNoteEvent.build("b")) - store.insert(a) - store.insert(b) - - store.delete(Filter(ids = listOf(a.id))) - - assertNull(store.query(Filter(ids = listOf(a.id))).firstOrNull()) - assertEquals(b.id, store.query(Filter(ids = listOf(b.id))).single().id) - } - - @Test - fun `insert of duplicate id is a no-op`() { - val note = signer.sign(TextNoteEvent.build("dup")) - store.insert(note) - store.insert(note) // must not throw; content is immutable anyway - assertEquals(1, store.count(Filter(ids = listOf(note.id)))) - } - - @Test - fun `ephemeral events are not persisted`() { - // Kind 20_000 is the lowest ephemeral kind; use a bare Event - // constructed inline because TextNoteEvent pins kind=1. - val ephemeral = - signer.sign( - createdAt = 1, - kind = 20_000, - tags = emptyArray(), - content = "ghost", - ) - store.insert(ephemeral) - assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id)))) - } - - @Test - fun `ids that share the same 4-char shard both persist`() { - // Find two real events whose ids share the same first 4 hex chars. - // With a random KeyPair per sign, this takes a handful of tries. - var a = signer.sign(TextNoteEvent.build("a0", createdAt = 1)) - var b: TextNoteEvent - var salt = 2L - do { - b = signer.sign(TextNoteEvent.build("b$salt", createdAt = salt)) - salt++ - } while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000) - if (b.id.substring(0, 4) != a.id.substring(0, 4)) { - // Didn't find a collision cheaply. Fall back to inserting two - // unrelated events and checking they both live under their own - // shards — still verifies basic sharding without flakiness. - b = signer.sign(TextNoteEvent.build("unrelated")) + val got = store.query(Filter(ids = listOf(note.id))) + assertEquals(1, got.size) + assertEquals(note.id, got[0].id) + assertEquals(note.content, got[0].content) + assertEquals(note.sig, got[0].sig) } - store.insert(a) - store.insert(b) - - assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) - assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) - } - @Test - fun `delete with empty filter is safe`() { - val a = signer.sign(TextNoteEvent.build("a", createdAt = 1)) - val b = signer.sign(TextNoteEvent.build("b", createdAt = 2)) - store.insert(a) - store.insert(b) + fun `canonical path uses 2-char sharding`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("shard me")) + store.insert(note) - // Empty filter: query returns everything, but delete must NOT - // wipe the store. Same safe-by-default contract as SQLiteEventStore. - assertEquals(2, store.count(Filter())) - store.delete(Filter()) - assertEquals(2, store.count(Filter())) - - store.delete(listOf(Filter(), Filter())) - assertEquals(2, store.count(Filter())) - } - - @Test - fun `staging dir is cleared on init`() { - val staging = root.resolve(".staging") - val leftover = Files.createTempFile(staging, "crash-", ".json") - assertTrue(leftover.exists()) - - // Reopening the store should sweep the staging dir. - val reopened = FsEventStore(root) - try { - assertFalse(leftover.exists(), "staging leftover should be cleared on open") - } finally { - reopened.close() + val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4)) + val file = shard.resolve("${note.id}.json") + assertTrue(file.exists(), "expected canonical at $file") + } + + @Test + fun `query returns empty when nothing inserted`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("missing")) + assertEquals(emptyList(), store.query(Filter(ids = listOf(note.id)))) + } + + @Test + fun `delete by id removes the file`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("to-delete")) + store.insert(note) + assertEquals(1, store.count(Filter(ids = listOf(note.id)))) + + val removed = store.delete(note.id) + assertEquals(1, removed) + assertEquals(0, store.count(Filter(ids = listOf(note.id)))) + } + + @Test + fun `delete returns 0 when event absent`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("never-inserted")) + assertEquals(0, store.delete(note.id)) + } + + @Test + fun `delete by filter with ids removes matching events`() = + runBlocking { + val a = signer.sign(TextNoteEvent.build("a")) + val b = signer.sign(TextNoteEvent.build("b")) + store.insert(a) + store.insert(b) + + store.delete(Filter(ids = listOf(a.id))) + + assertNull(store.query(Filter(ids = listOf(a.id))).firstOrNull()) + assertEquals(b.id, store.query(Filter(ids = listOf(b.id))).single().id) + } + + @Test + fun `insert of duplicate id is a no-op`() = + runBlocking { + val note = signer.sign(TextNoteEvent.build("dup")) + store.insert(note) + store.insert(note) // must not throw; content is immutable anyway + assertEquals(1, store.count(Filter(ids = listOf(note.id)))) + } + + @Test + fun `ephemeral events are not persisted`() = + runBlocking { + // Kind 20_000 is the lowest ephemeral kind; use a bare Event + // constructed inline because TextNoteEvent pins kind=1. + val ephemeral = + signer.sign( + createdAt = 1, + kind = 20_000, + tags = emptyArray(), + content = "ghost", + ) + store.insert(ephemeral) + assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id)))) + } + + @Test + fun `ids that share the same 4-char shard both persist`() = + runBlocking { + // Find two real events whose ids share the same first 4 hex chars. + // With a random KeyPair per sign, this takes a handful of tries. + var a = signer.sign(TextNoteEvent.build("a0", createdAt = 1)) + var b: TextNoteEvent + var salt = 2L + do { + b = signer.sign(TextNoteEvent.build("b$salt", createdAt = salt)) + salt++ + } while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000) + if (b.id.substring(0, 4) != a.id.substring(0, 4)) { + // Didn't find a collision cheaply. Fall back to inserting two + // unrelated events and checking they both live under their own + // shards — still verifies basic sharding without flakiness. + b = signer.sign(TextNoteEvent.build("unrelated")) + } + + store.insert(a) + store.insert(b) + + assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) + } + + @Test + fun `delete with empty filter is safe`() = + runBlocking { + val a = signer.sign(TextNoteEvent.build("a", createdAt = 1)) + val b = signer.sign(TextNoteEvent.build("b", createdAt = 2)) + store.insert(a) + store.insert(b) + + // Empty filter: query returns everything, but delete must NOT + // wipe the store. Same safe-by-default contract as SQLiteEventStore. + assertEquals(2, store.count(Filter())) + store.delete(Filter()) + assertEquals(2, store.count(Filter())) + + store.delete(listOf(Filter(), Filter())) + assertEquals(2, store.count(Filter())) + } + + @Test + fun `staging dir is cleared on init`() = + runBlocking { + val staging = root.resolve(".staging") + val leftover = Files.createTempFile(staging, "crash-", ".json") + assertTrue(leftover.exists()) + + // Reopening the store should sweep the staging dir. + val reopened = FsEventStore(root) + try { + assertFalse(leftover.exists(), "staging leftover should be cleared on open") + } finally { + reopened.close() + } } - } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt index 8132aedf3..31bdab099 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -54,63 +55,65 @@ class FsEventToJsonTest { } @Test - fun `default formatter writes compact JSON one line`() { - val store = FsEventStore(root) - try { - val n = - signer.sign( - TextNoteEvent.build("hello", createdAt = 100), - ) - store.insert(n) - val canonical = - root - .resolve("events") - .resolve(n.id.substring(0, 2)) - .resolve(n.id.substring(2, 4)) - .resolve("${n.id}.json") - val raw = canonical.readText() - assertEquals(raw.trim(), raw, "compact form has no trailing whitespace") - assertTrue(!raw.contains('\n'), "compact form is single-line") - } finally { - store.close() + fun `default formatter writes compact JSON one line`() = + runBlocking { + val store = FsEventStore(root) + try { + val n = + signer.sign( + TextNoteEvent.build("hello", createdAt = 100), + ) + store.insert(n) + val canonical = + root + .resolve("events") + .resolve(n.id.substring(0, 2)) + .resolve(n.id.substring(2, 4)) + .resolve("${n.id}.json") + val raw = canonical.readText() + assertEquals(raw.trim(), raw, "compact form has no trailing whitespace") + assertTrue(!raw.contains('\n'), "compact form is single-line") + } finally { + store.close() + } } - } @Test - fun `pretty formatter writes multi-line indented JSON and round-trips`() { - val store = - FsEventStore( - root, - eventToJson = JacksonMapper::toJsonPretty, - ) - try { - val n = - signer.sign( - TextNoteEvent.build("hello", createdAt = 100), + fun `pretty formatter writes multi-line indented JSON and round-trips`() = + runBlocking { + val store = + FsEventStore( + root, + eventToJson = JacksonMapper::toJsonPretty, ) - store.insert(n) - val canonical = - root - .resolve("events") - .resolve(n.id.substring(0, 2)) - .resolve(n.id.substring(2, 4)) - .resolve("${n.id}.json") - val raw = canonical.readText() - assertTrue(raw.contains('\n'), "pretty form is multi-line") - assertTrue(raw.contains("\"id\""), "field labels survive pretty print") + try { + val n = + signer.sign( + TextNoteEvent.build("hello", createdAt = 100), + ) + store.insert(n) + val canonical = + root + .resolve("events") + .resolve(n.id.substring(0, 2)) + .resolve(n.id.substring(2, 4)) + .resolve("${n.id}.json") + val raw = canonical.readText() + assertTrue(raw.contains('\n'), "pretty form is multi-line") + assertTrue(raw.contains("\"id\""), "field labels survive pretty print") - // Round-trip: parsing pretty output back must produce the same event. - val reparsed = Event.fromJson(raw) - assertEquals(n.id, reparsed.id) - assertEquals(n.content, reparsed.content) - assertEquals(n.sig, reparsed.sig) + // Round-trip: parsing pretty output back must produce the same event. + val reparsed = Event.fromJson(raw) + assertEquals(n.id, reparsed.id) + assertEquals(n.content, reparsed.content) + assertEquals(n.sig, reparsed.sig) - // And the store can read it back through its own API. - val got = store.query(Filter(ids = listOf(n.id))) - assertEquals(1, got.size) - assertEquals(n.id, got[0].id) - } finally { - store.close() + // And the store can read it back through its own API. + val got = store.query(Filter(ids = listOf(n.id))) + assertEquals(1, got.size) + assertEquals(n.id, got[0].id) + } finally { + store.close() + } } - } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt index cb1f04f29..db19a2b5c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -81,128 +82,136 @@ class FsExpirationTest { ) @Test - fun `event with future expiration is accepted and indexed`() { - clockNow = 1_000 - val e = expiringNote("future", createdAt = 500, expiresAt = 2_000) - store.insert(e) + fun `event with future expiration is accepted and indexed`() = + runBlocking { + clockNow = 1_000 + val e = expiringNote("future", createdAt = 500, expiresAt = 2_000) + store.insert(e) - assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) + assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) - val expIdx = root.resolve("idx/expires_at") - val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() } - assertEquals(1, entries.size, "expires_at index should hold exactly one entry") - assertTrue(entries.single().endsWith("-${e.id}")) - assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts") - } + val expIdx = root.resolve("idx/expires_at") + val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() } + assertEquals(1, entries.size, "expires_at index should hold exactly one entry") + assertTrue(entries.single().endsWith("-${e.id}")) + assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts") + } @Test - fun `event already expired at insert time is rejected`() { - clockNow = 5_000 - val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000) - store.insert(e) + fun `event already expired at insert time is rejected`() = + runBlocking { + clockNow = 5_000 + val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000) + store.insert(e) - assertEquals(emptyList(), store.query(Filter(ids = listOf(e.id))).map { it.id }) - assertFalse(store.hasCanonical(e.id)) - } + assertEquals(emptyList(), store.query(Filter(ids = listOf(e.id))).map { it.id }) + assertFalse(store.hasCanonical(e.id)) + } @Test - fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() { - clockNow = 5_000 - val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000) - store.insert(e) + fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() = + runBlocking { + clockNow = 5_000 + val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000) + store.insert(e) - assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)") - } + assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)") + } @Test - fun `non-positive expiration is ignored`() { - clockNow = 5_000 - val zero = expiringNote("zero", createdAt = 1, expiresAt = 0) - val neg = expiringNote("neg", createdAt = 2, expiresAt = -1) - store.insert(zero) - store.insert(neg) + fun `non-positive expiration is ignored`() = + runBlocking { + clockNow = 5_000 + val zero = expiringNote("zero", createdAt = 1, expiresAt = 0) + val neg = expiringNote("neg", createdAt = 2, expiresAt = -1) + store.insert(zero) + store.insert(neg) - assertTrue(store.hasCanonical(zero.id)) - assertTrue(store.hasCanonical(neg.id)) - // And nothing in idx/expires_at. - val expIdx = root.resolve("idx/expires_at") - assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed") - } + assertTrue(store.hasCanonical(zero.id)) + assertTrue(store.hasCanonical(neg.id)) + // And nothing in idx/expires_at. + val expIdx = root.resolve("idx/expires_at") + assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed") + } @Test - fun `deleteExpiredEvents sweeps everything past now`() { - clockNow = 1_000 - val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired - val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past - val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive + fun `deleteExpiredEvents sweeps everything past now`() = + runBlocking { + clockNow = 1_000 + val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired + val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past + val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive - // Insert at a fake earlier "now" so all three pass the insert guard. - clockNow = 99 - store.insert(a) - store.insert(b) - store.insert(c) + // Insert at a fake earlier "now" so all three pass the insert guard. + clockNow = 99 + store.insert(a) + store.insert(b) + store.insert(c) - // Advance the clock and sweep. - clockNow = 1_000 - store.deleteExpiredEvents() + // Advance the clock and sweep. + clockNow = 1_000 + store.deleteExpiredEvents() - assertFalse(store.hasCanonical(a.id), "a should be swept") - assertFalse(store.hasCanonical(b.id), "b should be swept") - assertTrue(store.hasCanonical(c.id), "c should survive") - } + assertFalse(store.hasCanonical(a.id), "a should be swept") + assertFalse(store.hasCanonical(b.id), "b should be swept") + assertTrue(store.hasCanonical(c.id), "c should survive") + } @Test - fun `sweep uses strict less-than parity with SQLite`() { - // SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert) - // SQLite sweep: WHERE expiration < unixepoch() (delete) - // Insert-time uses inclusive <=, sweep uses strict <. - clockNow = 50 - val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100) - store.insert(onTheTick) + fun `sweep uses strict less-than parity with SQLite`() = + runBlocking { + // SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert) + // SQLite sweep: WHERE expiration < unixepoch() (delete) + // Insert-time uses inclusive <=, sweep uses strict <. + clockNow = 50 + val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100) + store.insert(onTheTick) - clockNow = 100 // exp == now → sweep keeps it - store.deleteExpiredEvents() - assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept") + clockNow = 100 // exp == now → sweep keeps it + store.deleteExpiredEvents() + assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept") - clockNow = 101 - store.deleteExpiredEvents() - assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept") - } + clockNow = 101 + store.deleteExpiredEvents() + assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept") + } @Test - fun `sweep removes index entries too`() { - clockNow = 50 - val e = expiringNote("x", createdAt = 1, expiresAt = 100) - store.insert(e) + fun `sweep removes index entries too`() = + runBlocking { + clockNow = 50 + val e = expiringNote("x", createdAt = 1, expiresAt = 100) + store.insert(e) - val expIdx = root.resolve("idx/expires_at") - assertEquals(1, expIdx.listDirectoryEntries().size) + val expIdx = root.resolve("idx/expires_at") + assertEquals(1, expIdx.listDirectoryEntries().size) - clockNow = 1_000 - store.deleteExpiredEvents() - assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked") + clockNow = 1_000 + store.deleteExpiredEvents() + assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked") - // Author + kind index entries also gone. - val authorDir = root.resolve("idx/author/${signer.pubKey}") - if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size) - } + // Author + kind index entries also gone. + val authorDir = root.resolve("idx/author/${signer.pubKey}") + if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size) + } @Test - fun `events without expiration are unaffected by sweep`() { - clockNow = 100 - val plain = - signer.sign( - createdAt = 50, - kind = 1, - tags = emptyArray(), - content = "plain", - ) - store.insert(plain) + fun `events without expiration are unaffected by sweep`() = + runBlocking { + clockNow = 100 + val plain = + signer.sign( + createdAt = 50, + kind = 1, + tags = emptyArray(), + content = "plain", + ) + store.insert(plain) - clockNow = 1_000_000 - store.deleteExpiredEvents() - assertTrue(store.hasCanonical(plain.id)) - } + clockNow = 1_000_000 + store.deleteExpiredEvents() + assertTrue(store.hasCanonical(plain.id)) + } private fun FsEventStore.hasCanonical(id: String): Boolean { val p = diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt index b5b004716..38c8ec3e8 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsMaintenanceTest.kt @@ -24,6 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -65,194 +69,206 @@ class FsMaintenanceTest { // ------------------------------------------------------------------ @Test - fun `lock file is created on open`() { - assertTrue(root.resolve(".lock").exists()) - } + fun `lock file is created on open`() = + runBlocking { + assertTrue(root.resolve(".lock").exists()) + } // ------------------------------------------------------------------ // Transactions // ------------------------------------------------------------------ @Test - fun `transaction commits all inserts on success`() { - val a = note("a", 1) - val b = note("b", 2) - val c = note("c", 3) + fun `transaction commits all inserts on success`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3) - store.transaction { - insert(a) - insert(b) - insert(c) - } - - val got = store.query(Filter(authors = listOf(signer.pubKey))) - assertEquals(setOf(a.id, b.id, c.id), got.map { it.id }.toSet()) - } - - @Test - fun `transaction propagates exceptions and stops processing`() { - val a = note("a", 1) - val b = note("b", 2) - val c = note("c", 3) - - assertFailsWith { store.transaction { insert(a) insert(b) - throw IllegalStateException("boom") - // unreachable - @Suppress("UNREACHABLE_CODE") insert(c) } - } - // Events written before the throw are kept (per the plan: atomic- - // per-event, serialised across writers — not all-or-nothing). - assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) - assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) - assertTrue(store.count(Filter(ids = listOf(c.id))) == 0) - } + val got = store.query(Filter(authors = listOf(signer.pubKey))) + assertEquals(setOf(a.id, b.id, c.id), got.map { it.id }.toSet()) + } @Test - fun `transaction is re-entrant on the same thread`() { - val a = note("a", 1) - // If flock were non-reentrant we'd self-deadlock here because - // insert() acquires the same lock the transaction already holds. - store.transaction { - insert(a) - // Call an outer-locking method from within the transaction. - store.deleteExpiredEvents() + fun `transaction propagates exceptions and stops processing`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3) + + assertFailsWith { + store.transaction { + insert(a) + insert(b) + throw IllegalStateException("boom") + // unreachable + @Suppress("UNREACHABLE_CODE") + insert(c) + } + } + + // Events written before the throw are kept (per the plan: atomic- + // per-event, serialised across writers — not all-or-nothing). + assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(c.id))) == 0) + } + + @Test + fun `transaction is re-entrant on the same thread`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + // If flock were non-reentrant we'd self-deadlock here because + // insert() acquires the same lock the transaction already holds. + store.transaction { + insert(a) + insert(b) + } + // And a follow-up suspend call also re-enters the lock cleanly. + store.deleteExpiredEvents() + assertEquals(1, store.count(Filter(ids = listOf(a.id)))) + assertEquals(1, store.count(Filter(ids = listOf(b.id)))) } - assertEquals(1, store.count(Filter(ids = listOf(a.id)))) - } // ------------------------------------------------------------------ // scrub — rebuild idx/ from canonical // ------------------------------------------------------------------ @Test - fun `scrub rebuilds idx entries after a manual wipe`() { - val a = note("hello bitcoin", 10) - val b = note("nostr stuff", 20) - store.insert(a) - store.insert(b) + fun `scrub rebuilds idx entries after a manual wipe`() = + runBlocking { + val a = note("hello bitcoin", 10) + val b = note("nostr stuff", 20) + store.insert(a) + store.insert(b) - // Blow away the entire idx/ tree behind the store's back. - Files.walk(root.resolve("idx")).use { - it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } + // Blow away the entire idx/ tree behind the store's back. + Files.walk(root.resolve("idx")).use { + it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } + } + + // Without scrub, index-driven queries find nothing. + assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }) + + store.scrub() + + val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() + assertEquals(setOf(a.id, b.id), got) + + // FTS recovered too. + assertEquals(listOf(a.id), store.query(Filter(search = "bitcoin")).map { it.id }) } - // Without scrub, index-driven queries find nothing. - assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }) - - store.scrub() - - val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() - assertEquals(setOf(a.id, b.id), got) - - // FTS recovered too. - assertEquals(listOf(a.id), store.query(Filter(search = "bitcoin")).map { it.id }) - } - @Test - fun `scrub leaves replaceable slot intact`() { - // Replaceable slots pin events via hardlink even without the - // canonical. Scrub must not wipe slots. - val meta = - signer.sign( - createdAt = 10, - kind = 0, - tags = emptyArray(), - content = "{}", - ) - store.insert(meta) - val slot = root.resolve("replaceable/0/${signer.pubKey}.json") - assertTrue(slot.exists()) + fun `scrub leaves replaceable slot intact`() = + runBlocking { + // Replaceable slots pin events via hardlink even without the + // canonical. Scrub must not wipe slots. + val meta = + signer.sign( + createdAt = 10, + kind = 0, + tags = emptyArray(), + content = "{}", + ) + store.insert(meta) + val slot = root.resolve("replaceable/0/${signer.pubKey}.json") + assertTrue(slot.exists()) - store.scrub() - assertTrue(slot.exists(), "replaceable slot must survive scrub") - } + store.scrub() + assertTrue(slot.exists(), "replaceable slot must survive scrub") + } // ------------------------------------------------------------------ // compact — drop dangling idx entries // ------------------------------------------------------------------ @Test - fun `compact drops idx entries whose canonical is gone`() { - val a = note("x", 10) - store.insert(a) + fun `compact drops idx entries whose canonical is gone`() = + runBlocking { + val a = note("x", 10) + store.insert(a) - // Externally delete the canonical without touching idx/. - val canonical = root.resolve("events/${a.id.substring(0, 2)}/${a.id.substring(2, 4)}/${a.id}.json") - assertTrue(Files.deleteIfExists(canonical)) + // Externally delete the canonical without touching idx/. + val canonical = root.resolve("events/${a.id.substring(0, 2)}/${a.id.substring(2, 4)}/${a.id}.json") + assertTrue(Files.deleteIfExists(canonical)) - val kindDir = root.resolve("idx/kind/1") - assertEquals(1, kindDir.listDirectoryEntries().size, "dangling entry still present pre-compact") + val kindDir = root.resolve("idx/kind/1") + assertEquals(1, kindDir.listDirectoryEntries().size, "dangling entry still present pre-compact") - store.compact() + store.compact() - assertEquals(0, kindDir.listDirectoryEntries().size, "dangling entry dropped post-compact") - } + assertEquals(0, kindDir.listDirectoryEntries().size, "dangling entry dropped post-compact") + } @Test - fun `compact leaves valid entries alone`() { - val a = note("x", 10) - store.insert(a) + fun `compact leaves valid entries alone`() = + runBlocking { + val a = note("x", 10) + store.insert(a) - store.compact() + store.compact() - val kindDir = root.resolve("idx/kind/1") - assertEquals(1, kindDir.listDirectoryEntries().size, "valid entry should not be touched") - assertEquals(listOf(a.id), store.query(Filter(ids = listOf(a.id))).map { it.id }) - } + val kindDir = root.resolve("idx/kind/1") + assertEquals(1, kindDir.listDirectoryEntries().size, "valid entry should not be touched") + assertEquals(listOf(a.id), store.query(Filter(ids = listOf(a.id))).map { it.id }) + } // ------------------------------------------------------------------ // close // ------------------------------------------------------------------ @Test - fun `close is idempotent`() { - store.close() - store.close() - } + fun `close is idempotent`() = + runBlocking { + store.close() + store.close() + } @Test - fun `reopen after close works`() { - val a = note("a", 1) - store.insert(a) - store.close() + fun `reopen after close works`() = + runBlocking { + val a = note("a", 1) + store.insert(a) + store.close() - val reopened = FsEventStore(root) - try { - assertEquals(listOf(a.id), reopened.query(Filter(ids = listOf(a.id))).map { it.id }) - } finally { - reopened.close() + val reopened = FsEventStore(root) + try { + assertEquals(listOf(a.id), reopened.query(Filter(ids = listOf(a.id))).map { it.id }) + } finally { + reopened.close() + } } - } // ------------------------------------------------------------------ // Concurrency — two writer threads serialise cleanly // ------------------------------------------------------------------ @Test - fun `concurrent inserts on two threads are both persisted`() { - val events = (1..20).map { note("n$it", it.toLong()) } - val half = events.size / 2 + fun `concurrent inserts on two threads are both persisted`() = + runBlocking { + val events = (1..20).map { note("n$it", it.toLong()) } + val half = events.size / 2 - val t1 = - Thread { - events.take(half).forEach { store.insert(it) } + // Two real threads via Dispatchers.IO so the in-process lock has to + // arbitrate. join via coroutineScope. + coroutineScope { + launch(Dispatchers.IO) { + events.take(half).forEach { store.insert(it) } + } + launch(Dispatchers.IO) { + events.drop(half).forEach { store.insert(it) } + } } - val t2 = - Thread { - events.drop(half).forEach { store.insert(it) } - } - t1.start() - t2.start() - t1.join() - t2.join() - val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() - assertEquals(events.map { it.id }.toSet(), got) - } + val got = store.query(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() + assertEquals(events.map { it.id }.toSet(), got) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt index 7b18122f2..286c89433 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -81,7 +82,7 @@ class FsParityTest { } /** Insert into both stores. Swallow SQLite rejections (we only care about the resulting state). */ - private fun insertBoth(event: Event) { + private suspend fun insertBoth(event: Event) { try { sqlite.insert(event) } catch (_: Throwable) { @@ -93,7 +94,7 @@ class FsParityTest { } /** Assert both stores return the same ids (as a set) for the given filter. */ - private fun assertParity( + private suspend fun assertParity( filter: Filter, message: String = "", ) { @@ -103,7 +104,7 @@ class FsParityTest { } /** Same, but expect a stable DESC-by-createdAt ordering. */ - private fun assertParityOrdered( + private suspend fun assertParityOrdered( filter: Filter, message: String = "", ) { @@ -135,350 +136,368 @@ class FsParityTest { // ------------------------------------------------------------------ @Test - fun `id lookup matches`() { - val n = note("hello", 10) - insertBoth(n) - assertParity(Filter(ids = listOf(n.id))) - } + fun `id lookup matches`() = + runBlocking { + val n = note("hello", 10) + insertBoth(n) + assertParity(Filter(ids = listOf(n.id))) + } @Test - fun `kind + author query matches`() { - val a = note("a", 1) - val b = note("b", 2) - val c = note("c", 3, s = otherSigner) - listOf(a, b, c).forEach(::insertBoth) + fun `kind + author query matches`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3, s = otherSigner) + listOf(a, b, c).forEach { insertBoth(it) } - assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey))) - assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) - } + assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey))) + assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) + } @Test - fun `since until limit match`() { - repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) } - assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8)) - assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3)) - } + fun `since until limit match`() = + runBlocking { + repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) } + assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8)) + assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3)) + } // ------------------------------------------------------------------ // Tag indexing // ------------------------------------------------------------------ @Test - fun `single-letter tag queries match`() { - val tagged = - signer.sign( - createdAt = 5, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), - content = "x", - ) - val plain = note("plain", 6) - insertBoth(tagged) - insertBoth(plain) + fun `single-letter tag queries match`() = + runBlocking { + val tagged = + signer.sign( + createdAt = 5, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), + content = "x", + ) + val plain = note("plain", 6) + insertBoth(tagged) + insertBoth(plain) - assertParity(Filter(tags = mapOf("t" to listOf("nostr")))) - assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) - } + assertParity(Filter(tags = mapOf("t" to listOf("nostr")))) + assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) + } // ------------------------------------------------------------------ // Replaceable / Addressable // ------------------------------------------------------------------ @Test - fun `replaceable newer wins parity`() { - val v1 = - signer.sign( - createdAt = 100, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"v1\"}", - ) - val v2 = - signer.sign( - createdAt = 200, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"v2\"}", - ) - insertBoth(v1) - insertBoth(v2) + fun `replaceable newer wins parity`() = + runBlocking { + val v1 = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"v1\"}", + ) + val v2 = + signer.sign( + createdAt = 200, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"v2\"}", + ) + insertBoth(v1) + insertBoth(v2) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) - assertParity(Filter(ids = listOf(v1.id))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) + assertParity(Filter(ids = listOf(v1.id))) + } @Test - fun `replaceable older rejected parity`() { - val newer = - signer.sign(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}") - val older = - signer.sign(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}") - insertBoth(newer) - insertBoth(older) + fun `replaceable older rejected parity`() = + runBlocking { + val newer = + signer.sign(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}") + val older = + signer.sign(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}") + insertBoth(newer) + insertBoth(older) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) + } @Test - fun `addressable d-tag dedup parity`() { - val v1 = article("intro", "v1", 10) - val v2 = article("intro", "v2", 20) - val v3 = article("about", "bio", 15) - insertBoth(v1) - insertBoth(v2) - insertBoth(v3) + fun `addressable d-tag dedup parity`() = + runBlocking { + val v1 = article("intro", "v1", 10) + val v2 = article("intro", "v2", 20) + val v3 = article("about", "bio", 15) + insertBoth(v1) + insertBoth(v2) + insertBoth(v3) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + } @Test - fun `replaceable same-createdAt lexical id tiebreaker parity`() { - // Two kind-0 events with identical createdAt produce different ids - // because their content differs. NIP-01 says the lexically smaller - // id wins on a tie. Both stores must agree, regardless of insertion - // order. - val a = - signer.sign( - createdAt = 100, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"a\"}", + fun `replaceable same-createdAt lexical id tiebreaker parity`() = + runBlocking { + // Two kind-0 events with identical createdAt produce different ids + // because their content differs. NIP-01 says the lexically smaller + // id wins on a tie. Both stores must agree, regardless of insertion + // order. + val a = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"a\"}", + ) + val b = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"b\"}", + ) + + insertBoth(a) + insertBoth(b) + + assertParity( + Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), + "loser-then-winner: lexically smaller id should win", ) - val b = - signer.sign( - createdAt = 100, - kind = 0, - tags = emptyArray(), - content = "{\"name\":\"b\"}", + assertParity( + Filter(ids = listOf(a.id, b.id)), + "the loser must not survive in the by-id query", ) - - insertBoth(a) - insertBoth(b) - - assertParity( - Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), - "loser-then-winner: lexically smaller id should win", - ) - assertParity( - Filter(ids = listOf(a.id, b.id)), - "the loser must not survive in the by-id query", - ) - } + } @Test - fun `addressable same-createdAt lexical id tiebreaker parity`() { - val a = article("tie", "version a", 100) - val b = article("tie", "version b", 100) + fun `addressable same-createdAt lexical id tiebreaker parity`() = + runBlocking { + val a = article("tie", "version a", 100) + val b = article("tie", "version b", 100) - insertBoth(a) - insertBoth(b) + insertBoth(a) + insertBoth(b) - assertParity( - Filter( - authors = listOf(signer.pubKey), - kinds = listOf(LongTextNoteEvent.KIND), - tags = mapOf("d" to listOf("tie")), - ), - ) - assertParity(Filter(ids = listOf(a.id, b.id))) - } + assertParity( + Filter( + authors = listOf(signer.pubKey), + kinds = listOf(LongTextNoteEvent.KIND), + tags = mapOf("d" to listOf("tie")), + ), + ) + assertParity(Filter(ids = listOf(a.id, b.id))) + } // ------------------------------------------------------------------ // Deletion (NIP-09) // ------------------------------------------------------------------ @Test - fun `deletion by id parity`() { - val a = note("a", 10) - val b = note("b", 20) - insertBoth(a) - insertBoth(b) + fun `deletion by id parity`() = + runBlocking { + val a = note("a", 10) + val b = note("b", 20) + insertBoth(a) + insertBoth(b) - val del = signer.sign(DeletionEvent.build(listOf(a), createdAt = 30)) - insertBoth(del) + val del = signer.sign(DeletionEvent.build(listOf(a), createdAt = 30)) + insertBoth(del) - assertParity(Filter(ids = listOf(a.id))) - assertParity(Filter(ids = listOf(b.id))) - assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) + assertParity(Filter(ids = listOf(a.id))) + assertParity(Filter(ids = listOf(b.id))) + assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) - // Re-insert blocked. - insertBoth(a) - assertParity(Filter(ids = listOf(a.id))) - } + // Re-insert blocked. + insertBoth(a) + assertParity(Filter(ids = listOf(a.id))) + } @Test - fun `deletion by address parity`() { - val v = article("intro", "v1", 10) - insertBoth(v) + fun `deletion by address parity`() = + runBlocking { + val v = article("intro", "v1", 10) + insertBoth(v) - val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) - insertBoth(del) + val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + insertBoth(del) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - // Older event at this address must be blocked, newer must pass. - insertBoth(article("intro", "older", 5)) - insertBoth(article("intro", "newer", 100)) + // Older event at this address must be blocked, newer must pass. + insertBoth(article("intro", "older", 5)) + insertBoth(article("intro", "newer", 100)) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - } + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + } // ------------------------------------------------------------------ // Expiration (NIP-40) // ------------------------------------------------------------------ @Test - fun `expiration sweep parity`() { - // Build events with future-then-past expirations relative to now. - val now = - com.vitorpamplona.quartz.utils.TimeUtils - .now() - val expired = - signer.sign( - createdAt = now - 100, - kind = 1, - tags = arrayOf(arrayOf("expiration", (now - 50).toString())), - content = "old", - ) - val alive = - signer.sign( - createdAt = now - 100, - kind = 1, - tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())), - content = "still here", - ) - insertBoth(expired) // both stores reject (already expired) - insertBoth(alive) + fun `expiration sweep parity`() = + runBlocking { + // Build events with future-then-past expirations relative to now. + val now = + com.vitorpamplona.quartz.utils.TimeUtils + .now() + val expired = + signer.sign( + createdAt = now - 100, + kind = 1, + tags = arrayOf(arrayOf("expiration", (now - 50).toString())), + content = "old", + ) + val alive = + signer.sign( + createdAt = now - 100, + kind = 1, + tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())), + content = "still here", + ) + insertBoth(expired) // both stores reject (already expired) + insertBoth(alive) - assertParity(Filter(ids = listOf(expired.id))) - assertParity(Filter(ids = listOf(alive.id))) + assertParity(Filter(ids = listOf(expired.id))) + assertParity(Filter(ids = listOf(alive.id))) - // Sweep both; alive survives. - sqlite.deleteExpiredEvents() - fs.deleteExpiredEvents() - assertParity(Filter(ids = listOf(alive.id))) - } + // Sweep both; alive survives. + sqlite.deleteExpiredEvents() + fs.deleteExpiredEvents() + assertParity(Filter(ids = listOf(alive.id))) + } // ------------------------------------------------------------------ // Search (NIP-50) // ------------------------------------------------------------------ @Test - fun `search parity`() { - val a = note("hello bitcoin", 1) - val b = note("nostr only", 2) - val c = note("bitcoin and nostr", 3) - insertBoth(a) - insertBoth(b) - insertBoth(c) + fun `search parity`() = + runBlocking { + val a = note("hello bitcoin", 1) + val b = note("nostr only", 2) + val c = note("bitcoin and nostr", 3) + insertBoth(a) + insertBoth(b) + insertBoth(c) - // Tokenizers differ slightly between SQLite FTS5 unicode61 and - // our Kotlin port, so we stick to plain ASCII single-token queries - // where both should agree. - assertParity(Filter(search = "bitcoin")) - assertParity(Filter(search = "nostr")) - } + // Tokenizers differ slightly between SQLite FTS5 unicode61 and + // our Kotlin port, so we stick to plain ASCII single-token queries + // where both should agree. + assertParity(Filter(search = "bitcoin")) + assertParity(Filter(search = "nostr")) + } // ------------------------------------------------------------------ // Count // ------------------------------------------------------------------ @Test - fun `count parity across mixed stream`() { - listOf( - note("a", 1), - note("b", 2), - note("c", 3), - note("from-other", 4, s = otherSigner), - ).forEach(::insertBoth) + fun `count parity across mixed stream`() = + runBlocking { + listOf( + note("a", 1), + note("b", 2), + note("c", 3), + note("from-other", 4, s = otherSigner), + ).forEach { insertBoth(it) } - val filter = Filter(authors = listOf(signer.pubKey)) - assertEquals(sqlite.count(filter), fs.count(filter)) - } + val filter = Filter(authors = listOf(signer.pubKey)) + assertEquals(sqlite.count(filter), fs.count(filter)) + } // ------------------------------------------------------------------ // Mixed kitchen-sink scenario // ------------------------------------------------------------------ @Test - fun `kitchen sink scenario`() { - // Notes - val n1 = note("first", 1) - val n2 = note("second", 2) - // Replaceable - val meta1 = - signer.sign(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}") - val meta2 = - signer.sign(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}") - // Addressable - val artA = article("a", "A v1", 30) - val artB = article("b", "B v1", 30) - val artBv2 = article("b", "B v2", 50) - // Deletion of n1 - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 40)) + fun `kitchen sink scenario`() = + runBlocking { + // Notes + val n1 = note("first", 1) + val n2 = note("second", 2) + // Replaceable + val meta1 = + signer.sign(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}") + val meta2 = + signer.sign(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}") + // Addressable + val artA = article("a", "A v1", 30) + val artB = article("b", "B v1", 30) + val artBv2 = article("b", "B v2", 50) + // Deletion of n1 + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 40)) - listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach(::insertBoth) + listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach { insertBoth(it) } - // Snapshots that should match. - assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") - assertParity(Filter(ids = listOf(n2.id)), "n2 alive") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") - } + // Snapshots that should match. + assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") + assertParity(Filter(ids = listOf(n2.id)), "n2 alive") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") + } // ------------------------------------------------------------------ // Multi-filter union // ------------------------------------------------------------------ @Test - fun `multi-filter union parity`() { - val a = note("a", 1) - val b = note("b", 2, s = otherSigner) - insertBoth(a) - insertBoth(b) + fun `multi-filter union parity`() = + runBlocking { + val a = note("a", 1) + val b = note("b", 2, s = otherSigner) + insertBoth(a) + insertBoth(b) - val filters = - listOf( - Filter(authors = listOf(signer.pubKey)), - Filter(authors = listOf(otherSigner.pubKey)), + val filters = + listOf( + Filter(authors = listOf(signer.pubKey)), + Filter(authors = listOf(otherSigner.pubKey)), + ) + + assertEquals( + sqlite.query(filters).map { it.id }.toSet(), + fs.query(filters).map { it.id }.toSet(), ) - - assertEquals( - sqlite.query(filters).map { it.id }.toSet(), - fs.query(filters).map { it.id }.toSet(), - ) - } + } // ------------------------------------------------------------------ // Direct delete by filter // ------------------------------------------------------------------ @Test - fun `delete by filter parity`() { - val toKill = note("dead", 5) - val survivor = note("alive", 6) - insertBoth(toKill) - insertBoth(survivor) + fun `delete by filter parity`() = + runBlocking { + val toKill = note("dead", 5) + val survivor = note("alive", 6) + insertBoth(toKill) + insertBoth(survivor) - sqlite.delete(Filter(ids = listOf(toKill.id))) - fs.delete(Filter(ids = listOf(toKill.id))) + sqlite.delete(Filter(ids = listOf(toKill.id))) + fs.delete(Filter(ids = listOf(toKill.id))) - assertParity(Filter(authors = listOf(signer.pubKey))) - } + assertParity(Filter(authors = listOf(signer.pubKey))) + } // ------------------------------------------------------------------ // Helper: ensure SQLite store really does what we think // ------------------------------------------------------------------ @Test - fun `helper sanity - empty stores agree`() { - assertParity(Filter(authors = listOf(signer.pubKey))) - assertParity(Filter(kinds = listOf(1))) - } + fun `helper sanity - empty stores agree`() = + runBlocking { + assertParity(Filter(authors = listOf(signer.pubKey))) + assertParity(Filter(kinds = listOf(1))) + } @Suppress("unused") - private fun debugDump(label: String): String { + private suspend fun debugDump(label: String): String { val sqIds = sqlite .query(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt index 42989502a..3d051fd63 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsQueryTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -71,351 +72,372 @@ class FsQueryTest { // ------------------------------------------------------------------ @Test - fun `results ordered by created_at DESC`() { - val a = signA("a", 1) - val b = signA("b", 3) - val c = signA("c", 2) - listOf(a, b, c).forEach(store::insert) + fun `results ordered by created_at DESC`() = + runBlocking { + val a = signA("a", 1) + val b = signA("b", 3) + val c = signA("c", 2) + listOf(a, b, c).forEach { store.insert(it) } - val got = store.query(Filter(authors = listOf(signerA.pubKey))) - assertEquals(listOf(b.id, c.id, a.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signerA.pubKey))) + assertEquals(listOf(b.id, c.id, a.id), got.map { it.id }) + } @Test - fun `limit caps the result count`() { - repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) } - val got = store.query(Filter(authors = listOf(signerA.pubKey), limit = 2)) - assertEquals(2, got.size) - // Highest timestamps come first. - assertEquals("n4", got[0].content) - assertEquals("n3", got[1].content) - } + fun `limit caps the result count`() = + runBlocking { + repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) } + val got = store.query(Filter(authors = listOf(signerA.pubKey), limit = 2)) + assertEquals(2, got.size) + // Highest timestamps come first. + assertEquals("n4", got[0].content) + assertEquals("n3", got[1].content) + } @Test - fun `limit of zero returns empty`() { - store.insert(signA("x", 1)) - assertEquals(emptyList(), store.query(Filter(authors = listOf(signerA.pubKey), limit = 0))) - } + fun `limit of zero returns empty`() = + runBlocking { + store.insert(signA("x", 1)) + assertEquals(emptyList(), store.query(Filter(authors = listOf(signerA.pubKey), limit = 0))) + } // ------------------------------------------------------------------ // Author / kind drivers // ------------------------------------------------------------------ @Test - fun `author filter isolates one user`() { - val a = signA("from-a", 1) - val b = signB("from-b", 2) - store.insert(a) - store.insert(b) + fun `author filter isolates one user`() = + runBlocking { + val a = signA("from-a", 1) + val b = signB("from-b", 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(authors = listOf(signerA.pubKey))) - assertEquals(listOf(a.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signerA.pubKey))) + assertEquals(listOf(a.id), got.map { it.id }) + } @Test - fun `author filter with multiple authors unions them`() { - val a = signA("a", 1) - val b = signB("b", 2) - store.insert(a) - store.insert(b) + fun `author filter with multiple authors unions them`() = + runBlocking { + val a = signA("a", 1) + val b = signB("b", 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(authors = listOf(signerA.pubKey, signerB.pubKey))) - assertEquals(setOf(a.id, b.id), got.map { it.id }.toSet()) - } + val got = store.query(Filter(authors = listOf(signerA.pubKey, signerB.pubKey))) + assertEquals(setOf(a.id, b.id), got.map { it.id }.toSet()) + } @Test - fun `kind filter returns only the requested kinds`() { - // Build two events of different kinds. - val note = signA("note", 1) - val ephemeralKinds = signerA.sign(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article") - store.insert(note) - store.insert(ephemeralKinds) + fun `kind filter returns only the requested kinds`() = + runBlocking { + // Build two events of different kinds. + val note = signA("note", 1) + val ephemeralKinds = signerA.sign(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article") + store.insert(note) + store.insert(ephemeralKinds) - val onlyNotes = store.query(Filter(kinds = listOf(1))) - assertEquals(listOf(note.id), onlyNotes.map { it.id }) + val onlyNotes = store.query(Filter(kinds = listOf(1))) + assertEquals(listOf(note.id), onlyNotes.map { it.id }) - val onlyArticles = store.query(Filter(kinds = listOf(30023))) - assertEquals(listOf(ephemeralKinds.id), onlyArticles.map { it.id }) - } + val onlyArticles = store.query(Filter(kinds = listOf(30023))) + assertEquals(listOf(ephemeralKinds.id), onlyArticles.map { it.id }) + } @Test - fun `kind + author intersect via post-filter`() { - val a = signA("a", 1) - val b = signB("b", 2) - store.insert(a) - store.insert(b) + fun `kind + author intersect via post-filter`() = + runBlocking { + val a = signA("a", 1) + val b = signB("b", 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(kinds = listOf(1), authors = listOf(signerA.pubKey))) - assertEquals(listOf(a.id), got.map { it.id }) - } + val got = store.query(Filter(kinds = listOf(1), authors = listOf(signerA.pubKey))) + assertEquals(listOf(a.id), got.map { it.id }) + } // ------------------------------------------------------------------ // Tag driver // ------------------------------------------------------------------ @Test - fun `tag filter matches single-letter tags`() { - val tagged = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), - content = "tagged", - ) - val untagged = signA("plain", 5) - store.insert(tagged) - store.insert(untagged) + fun `tag filter matches single-letter tags`() = + runBlocking { + val tagged = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), + content = "tagged", + ) + val untagged = signA("plain", 5) + store.insert(tagged) + store.insert(untagged) - val got = store.query(Filter(tags = mapOf("t" to listOf("nostr")))) - assertEquals(listOf(tagged.id), got.map { it.id }) - } + val got = store.query(Filter(tags = mapOf("t" to listOf("nostr")))) + assertEquals(listOf(tagged.id), got.map { it.id }) + } @Test - fun `tag OR within key returns union`() { - val t1 = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n") - val t2 = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b") - val t3 = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o") - listOf(t1, t2, t3).forEach(store::insert) + fun `tag OR within key returns union`() = + runBlocking { + val t1 = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n") + val t2 = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b") + val t3 = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o") + listOf(t1, t2, t3).forEach { store.insert(it) } - val got = store.query(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) - assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet()) - } + val got = store.query(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) + assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet()) + } @Test - fun `tagsAll across keys requires all matches`() { - val both = - signerA.sign( - createdAt = 1, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr"), arrayOf("e", "a".repeat(64))), - content = "both", - ) - val onlyT = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only") - val onlyE = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only") - listOf(both, onlyT, onlyE).forEach(store::insert) + fun `tagsAll across keys requires all matches`() = + runBlocking { + val both = + signerA.sign( + createdAt = 1, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("e", "a".repeat(64))), + content = "both", + ) + val onlyT = signerA.sign(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only") + val onlyE = signerA.sign(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only") + listOf(both, onlyT, onlyE).forEach { store.insert(it) } - val got = - store.query( - Filter( - tagsAll = mapOf("t" to listOf("nostr"), "e" to listOf("a".repeat(64))), - ), - ) - assertEquals(listOf(both.id), got.map { it.id }) - } + val got = + store.query( + Filter( + tagsAll = mapOf("t" to listOf("nostr"), "e" to listOf("a".repeat(64))), + ), + ) + assertEquals(listOf(both.id), got.map { it.id }) + } // ------------------------------------------------------------------ // Tag-value directory naming: raw when fs-safe, _h_ otherwise. // ------------------------------------------------------------------ @Test - fun `safe ASCII tag values get raw directory names`() { - val e = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr")), - content = "x", - ) - store.insert(e) - // The raw value is the directory name — directly inspectable. - val rawDir = root.resolve("idx/tag/t/nostr") - assertTrue(rawDir.exists(), "ASCII-safe tag should land in idx/tag/t/nostr/") - assertEquals(1, rawDir.listDirectoryEntries().size) - } + fun `safe ASCII tag values get raw directory names`() = + runBlocking { + val e = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr")), + content = "x", + ) + store.insert(e) + // The raw value is the directory name — directly inspectable. + val rawDir = root.resolve("idx/tag/t/nostr") + assertTrue(rawDir.exists(), "ASCII-safe tag should land in idx/tag/t/nostr/") + assertEquals(1, rawDir.listDirectoryEntries().size) + } @Test - fun `pubkey p-tag uses raw 64-hex directory name`() { - // The motivating case: notifications. p-tags pointing at a - // pubkey land under idx/tag/p// — no hash, directly - // ls-able. - val target = signerB.pubKey - val e = - signerA.sign( - createdAt = 5, - kind = 1, - tags = arrayOf(arrayOf("p", target)), - content = "@you", - ) - store.insert(e) - val pDir = root.resolve("idx/tag/p/$target") - assertTrue(pDir.exists(), "p-tag pubkey should be ls-able directly: idx/tag/p/$target/") - assertEquals(1, pDir.listDirectoryEntries().size) - } + fun `pubkey p-tag uses raw 64-hex directory name`() = + runBlocking { + // The motivating case: notifications. p-tags pointing at a + // pubkey land under idx/tag/p// — no hash, directly + // ls-able. + val target = signerB.pubKey + val e = + signerA.sign( + createdAt = 5, + kind = 1, + tags = arrayOf(arrayOf("p", target)), + content = "@you", + ) + store.insert(e) + val pDir = root.resolve("idx/tag/p/$target") + assertTrue(pDir.exists(), "p-tag pubkey should be ls-able directly: idx/tag/p/$target/") + assertEquals(1, pDir.listDirectoryEntries().size) + } @Test - fun `tag value with emoji falls back to hashed directory name`() { - val e = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("t", "🔥")), - content = "x", - ) - store.insert(e) - // Exactly one entry under t/ and it must be in the _h_ hash - // bucket — emoji is not fs-safe. - val tDir = root.resolve("idx/tag/t") - val entries = tDir.listDirectoryEntries().map { it.fileName.toString() } - assertEquals(1, entries.size, "expected one bucket dir, got: $entries") - assertTrue(entries.single().startsWith("_h_"), "emoji tag must hash; got '${entries.single()}'") - } + fun `tag value with emoji falls back to hashed directory name`() = + runBlocking { + val e = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("t", "🔥")), + content = "x", + ) + store.insert(e) + // Exactly one entry under t/ and it must be in the _h_ hash + // bucket — emoji is not fs-safe. + val tDir = root.resolve("idx/tag/t") + val entries = tDir.listDirectoryEntries().map { it.fileName.toString() } + assertEquals(1, entries.size, "expected one bucket dir, got: $entries") + assertTrue(entries.single().startsWith("_h_"), "emoji tag must hash; got '${entries.single()}'") + } @Test - fun `tag value containing a slash falls back to hashed directory name`() { - val e = - signerA.sign( - createdAt = 10, - kind = 1, - tags = arrayOf(arrayOf("r", "https://example.com/page")), - content = "x", - ) - store.insert(e) - val rDir = root.resolve("idx/tag/r") - val entries = rDir.listDirectoryEntries().map { it.fileName.toString() } - assertEquals(1, entries.size, "expected one bucket dir, got: $entries") - assertTrue(entries.single().startsWith("_h_"), "URL tag must hash; got '${entries.single()}'") - } + fun `tag value containing a slash falls back to hashed directory name`() = + runBlocking { + val e = + signerA.sign( + createdAt = 10, + kind = 1, + tags = arrayOf(arrayOf("r", "https://example.com/page")), + content = "x", + ) + store.insert(e) + val rDir = root.resolve("idx/tag/r") + val entries = rDir.listDirectoryEntries().map { it.fileName.toString() } + assertEquals(1, entries.size, "expected one bucket dir, got: $entries") + assertTrue(entries.single().startsWith("_h_"), "URL tag must hash; got '${entries.single()}'") + } @Test - fun `query round-trips for both raw and hashed values`() { - // Each query must use the same naming rule as the writer or it - // walks a directory that doesn't exist. Insert both a raw-safe - // and a hash-required tag and verify they're both findable. - val safe = - signerA.sign( - createdAt = 1, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr")), - content = "safe", + fun `query round-trips for both raw and hashed values`() = + runBlocking { + // Each query must use the same naming rule as the writer or it + // walks a directory that doesn't exist. Insert both a raw-safe + // and a hash-required tag and verify they're both findable. + val safe = + signerA.sign( + createdAt = 1, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr")), + content = "safe", + ) + val unsafe = + signerA.sign( + createdAt = 2, + kind = 1, + tags = arrayOf(arrayOf("t", "🔥")), + content = "unsafe", + ) + store.insert(safe) + store.insert(unsafe) + assertEquals( + listOf(safe.id), + store.query(Filter(tags = mapOf("t" to listOf("nostr")))).map { it.id }, ) - val unsafe = - signerA.sign( - createdAt = 2, - kind = 1, - tags = arrayOf(arrayOf("t", "🔥")), - content = "unsafe", + assertEquals( + listOf(unsafe.id), + store.query(Filter(tags = mapOf("t" to listOf("🔥")))).map { it.id }, ) - store.insert(safe) - store.insert(unsafe) - assertEquals( - listOf(safe.id), - store.query(Filter(tags = mapOf("t" to listOf("nostr")))).map { it.id }, - ) - assertEquals( - listOf(unsafe.id), - store.query(Filter(tags = mapOf("t" to listOf("🔥")))).map { it.id }, - ) - } + } @Test - fun `non-single-letter tags are not reverse-indexed`() { - // SQLite parity: DefaultIndexingStrategy only indexes single-letter - // tag names, so a tag-driven query for `mytag = foo` finds no - // candidates. The event is still persisted and can be fetched via - // id / author / kind — just not via a reverse tag lookup. - val e = - signerA.sign( - createdAt = 1, - kind = 1, - tags = arrayOf(arrayOf("mytag", "foo")), - content = "x", - ) - store.insert(e) + fun `non-single-letter tags are not reverse-indexed`() = + runBlocking { + // SQLite parity: DefaultIndexingStrategy only indexes single-letter + // tag names, so a tag-driven query for `mytag = foo` finds no + // candidates. The event is still persisted and can be fetched via + // id / author / kind — just not via a reverse tag lookup. + val e = + signerA.sign( + createdAt = 1, + kind = 1, + tags = arrayOf(arrayOf("mytag", "foo")), + content = "x", + ) + store.insert(e) - assertEquals(emptyList(), store.query(Filter(tags = mapOf("mytag" to listOf("foo")))).map { it.id }) - assertEquals(listOf(e.id), store.query(Filter(authors = listOf(signerA.pubKey))).map { it.id }) - assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) - } + assertEquals(emptyList(), store.query(Filter(tags = mapOf("mytag" to listOf("foo")))).map { it.id }) + assertEquals(listOf(e.id), store.query(Filter(authors = listOf(signerA.pubKey))).map { it.id }) + assertEquals(listOf(e.id), store.query(Filter(ids = listOf(e.id))).map { it.id }) + } // ------------------------------------------------------------------ // since / until // ------------------------------------------------------------------ @Test - fun `since and until window filter`() { - val e1 = signA("t1", 100) - val e2 = signA("t2", 200) - val e3 = signA("t3", 300) - listOf(e1, e2, e3).forEach(store::insert) + fun `since and until window filter`() = + runBlocking { + val e1 = signA("t1", 100) + val e2 = signA("t2", 200) + val e3 = signA("t3", 300) + listOf(e1, e2, e3).forEach { store.insert(it) } - val got = store.query(Filter(since = 150, until = 250)) - assertEquals(listOf(e2.id), got.map { it.id }) - } + val got = store.query(Filter(since = 150, until = 250)) + assertEquals(listOf(e2.id), got.map { it.id }) + } // ------------------------------------------------------------------ // count // ------------------------------------------------------------------ @Test - fun `count matches query size`() { - repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) } - val filter = Filter(authors = listOf(signerA.pubKey)) - assertEquals(store.query(filter).size, store.count(filter)) - } + fun `count matches query size`() = + runBlocking { + repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) } + val filter = Filter(authors = listOf(signerA.pubKey)) + assertEquals(store.query(filter).size, store.count(filter)) + } // ------------------------------------------------------------------ // Index hardlink maintenance // ------------------------------------------------------------------ @Test - fun `insert creates hardlinks in every expected index dir`() { - val tagged = - signerA.sign( - createdAt = 42, - kind = 1, - tags = arrayOf(arrayOf("t", "nostr")), - content = "x", - ) - store.insert(tagged) + fun `insert creates hardlinks in every expected index dir`() = + runBlocking { + val tagged = + signerA.sign( + createdAt = 42, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr")), + content = "x", + ) + store.insert(tagged) - val kindDir = root.resolve("idx/kind/1") - val authorDir = root.resolve("idx/author/${signerA.pubKey}") - assertTrue(kindDir.exists() && kindDir.listDirectoryEntries().size == 1, "kind index missing") - assertTrue(authorDir.exists() && authorDir.listDirectoryEntries().size == 1, "author index missing") - val tagNameDir = root.resolve("idx/tag/t") - assertTrue(tagNameDir.exists(), "tag 't' dir missing") - val tagValueDirs = tagNameDir.listDirectoryEntries() - assertEquals(1, tagValueDirs.size, "exactly one tag-value subdir expected") - assertEquals(1, tagValueDirs[0].listDirectoryEntries().size, "tag-value dir should contain one entry") - } + val kindDir = root.resolve("idx/kind/1") + val authorDir = root.resolve("idx/author/${signerA.pubKey}") + assertTrue(kindDir.exists() && kindDir.listDirectoryEntries().size == 1, "kind index missing") + assertTrue(authorDir.exists() && authorDir.listDirectoryEntries().size == 1, "author index missing") + val tagNameDir = root.resolve("idx/tag/t") + assertTrue(tagNameDir.exists(), "tag 't' dir missing") + val tagValueDirs = tagNameDir.listDirectoryEntries() + assertEquals(1, tagValueDirs.size, "exactly one tag-value subdir expected") + assertEquals(1, tagValueDirs[0].listDirectoryEntries().size, "tag-value dir should contain one entry") + } @Test - fun `delete removes hardlinks so directories become empty`() { - val e = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") - store.insert(e) - store.delete(e.id) + fun `delete removes hardlinks so directories become empty`() = + runBlocking { + val e = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") + store.insert(e) + store.delete(e.id) - val kindDir = root.resolve("idx/kind/1") - val authorDir = root.resolve("idx/author/${signerA.pubKey}") - val tagValueDirs = - root - .resolve("idx/tag/t") - .takeIf { it.exists() } - ?.listDirectoryEntries() - .orEmpty() + val kindDir = root.resolve("idx/kind/1") + val authorDir = root.resolve("idx/author/${signerA.pubKey}") + val tagValueDirs = + root + .resolve("idx/tag/t") + .takeIf { it.exists() } + ?.listDirectoryEntries() + .orEmpty() - // Directories may remain as empty husks — what matters is the entries are gone. - if (kindDir.exists()) assertEquals(0, kindDir.listDirectoryEntries().size, "kind entry leaked") - if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size, "author entry leaked") - tagValueDirs.forEach { assertEquals(0, it.listDirectoryEntries().size, "tag entry leaked") } - } + // Directories may remain as empty husks — what matters is the entries are gone. + if (kindDir.exists()) assertEquals(0, kindDir.listDirectoryEntries().size, "kind entry leaked") + if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size, "author entry leaked") + tagValueDirs.forEach { assertEquals(0, it.listDirectoryEntries().size, "tag entry leaked") } + } // ------------------------------------------------------------------ // Seed persistence across reopen // ------------------------------------------------------------------ @Test - fun `reopening the store preserves queryability`() { - val tagged = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") - store.insert(tagged) - store.close() + fun `reopening the store preserves queryability`() = + runBlocking { + val tagged = signerA.sign(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") + store.insert(tagged) + store.close() - val reopened = FsEventStore(root) - try { - val got = reopened.query(Filter(tags = mapOf("t" to listOf("nostr")))) - assertEquals(listOf(tagged.id), got.map { it.id }) - } finally { - reopened.close() + val reopened = FsEventStore(root) + try { + val got = reopened.query(Filter(tags = mapOf("t" to listOf("nostr")))) + assertEquals(listOf(tagged.id), got.map { it.id }) + } finally { + reopened.close() + } } - } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt index 201efaad3..3678d795c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -66,193 +67,209 @@ class FsSearchTest { // ------------------------------------------------------------------ @Test - fun `tokenizer splits on whitespace and punctuation`() { - assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!")) - } + fun `tokenizer splits on whitespace and punctuation`() = + runBlocking { + assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!")) + } @Test - fun `tokenizer is case insensitive`() { - assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN")) - assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin")) - } + fun `tokenizer is case insensitive`() = + runBlocking { + assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN")) + assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin")) + } @Test - fun `tokenizer handles empty and punctuation-only strings`() { - assertEquals(emptySet(), FsSearchTokenizer.tokenize("")) - assertEquals(emptySet(), FsSearchTokenizer.tokenize("...")) - assertEquals(emptySet(), FsSearchTokenizer.tokenize(" ")) - } + fun `tokenizer handles empty and punctuation-only strings`() = + runBlocking { + assertEquals(emptySet(), FsSearchTokenizer.tokenize("")) + assertEquals(emptySet(), FsSearchTokenizer.tokenize("...")) + assertEquals(emptySet(), FsSearchTokenizer.tokenize(" ")) + } @Test - fun `tokenizer keeps unicode letters`() { - assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über")) - } + fun `tokenizer keeps unicode letters`() = + runBlocking { + assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über")) + } // ------------------------------------------------------------------ // Index maintenance // ------------------------------------------------------------------ @Test - fun `searchable event creates one fts entry per unique token`() { - val n = note("bitcoin nostr bitcoin", ts = 100) - store.insert(n) + fun `searchable event creates one fts entry per unique token`() = + runBlocking { + val n = note("bitcoin nostr bitcoin", ts = 100) + store.insert(n) - val ftsRoot = root.resolve("idx/fts") - val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet() - // TextNoteEvent.indexableContent() prepends a "Subject: " prefix so - // we get the content tokens plus the subject ones. What matters is - // that each unique token yields exactly one entry under its dir. - assertTrue("bitcoin" in tokenDirs) - assertTrue("nostr" in tokenDirs) - assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) - assertEquals(1, ftsRoot.resolve("nostr").listDirectoryEntries().size) - } - - @Test - fun `non-searchable event does not produce fts entries`() { - val meta = - signer.sign( - createdAt = 1, - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = "{\"name\":\"vitor\"}", - ) - store.insert(meta) - - val ftsRoot = root.resolve("idx/fts") - assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent") - } - - @Test - fun `delete removes fts entries`() { - val n = note("bitcoin nostr", ts = 100) - store.insert(n) - store.delete(n.id) - - val ftsRoot = root.resolve("idx/fts") - // Token directories may remain as empty husks. - for (tokenDir in ftsRoot.listDirectoryEntries()) { - assertEquals(0, tokenDir.listDirectoryEntries().size, "token entry leaked: $tokenDir") + val ftsRoot = root.resolve("idx/fts") + val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet() + // TextNoteEvent.indexableContent() prepends a "Subject: " prefix so + // we get the content tokens plus the subject ones. What matters is + // that each unique token yields exactly one entry under its dir. + assertTrue("bitcoin" in tokenDirs) + assertTrue("nostr" in tokenDirs) + assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) + assertEquals(1, ftsRoot.resolve("nostr").listDirectoryEntries().size) + } + + @Test + fun `non-searchable event does not produce fts entries`() = + runBlocking { + val meta = + signer.sign( + createdAt = 1, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "{\"name\":\"vitor\"}", + ) + store.insert(meta) + + val ftsRoot = root.resolve("idx/fts") + assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent") + } + + @Test + fun `delete removes fts entries`() = + runBlocking { + val n = note("bitcoin nostr", ts = 100) + store.insert(n) + store.delete(n.id) + + val ftsRoot = root.resolve("idx/fts") + // Token directories may remain as empty husks. + for (tokenDir in ftsRoot.listDirectoryEntries()) { + assertEquals(0, tokenDir.listDirectoryEntries().size, "token entry leaked: $tokenDir") + } } - } // ------------------------------------------------------------------ // Search query semantics // ------------------------------------------------------------------ @Test - fun `single-token search returns the matching event`() { - val a = note("bitcoin is fun", ts = 1) - val b = note("nostr is also fun", ts = 2) - store.insert(a) - store.insert(b) + fun `single-token search returns the matching event`() = + runBlocking { + val a = note("bitcoin is fun", ts = 1) + val b = note("nostr is also fun", ts = 2) + store.insert(a) + store.insert(b) - val got = store.query(Filter(search = "bitcoin")) - assertEquals(listOf(a.id), got.map { it.id }) - } - - @Test - fun `multi-token search is AND across tokens`() { - val a = note("bitcoin only", ts = 1) - val b = note("nostr only", ts = 2) - val c = note("bitcoin and nostr", ts = 3) - store.insert(a) - store.insert(b) - store.insert(c) - - val got = store.query(Filter(search = "bitcoin nostr")) - assertEquals(listOf(c.id), got.map { it.id }, "AND semantics: only the doc with both tokens matches") - } - - @Test - fun `search results are ordered by createdAt DESC`() { - val older = note("bitcoin first", ts = 10) - val newer = note("bitcoin again", ts = 20) - store.insert(older) - store.insert(newer) - - val got = store.query(Filter(search = "bitcoin")) - assertEquals(listOf(newer.id, older.id), got.map { it.id }) - } - - @Test - fun `search respects limit`() { - repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) } - val got = store.query(Filter(search = "bitcoin", limit = 2)) - assertEquals(2, got.size) - } - - @Test - fun `search composes with kinds and authors via post-filter`() { - val match = note("bitcoin maximalism", ts = 5) - store.insert(match) - - val got = - store.query( - Filter(search = "bitcoin", kinds = listOf(1), authors = listOf(signer.pubKey)), - ) - assertEquals(listOf(match.id), got.map { it.id }) - - val miss = - store.query( - Filter(search = "bitcoin", kinds = listOf(2)), - ) - assertEquals(emptyList(), miss.map { it.id }) - } - - @Test - fun `search with no matching token returns empty`() { - store.insert(note("nostr only", ts = 1)) - assertEquals( - emptyList(), - store.query(Filter(search = "bitcoin")).map { it.id }, - ) - } - - @Test - fun `blank search string is ignored`() { - val a = note("anything", ts = 1) - store.insert(a) - // Blank search shouldn't drive by FTS — the planner falls through - // to all-kinds, and the event surfaces. - val got = store.query(Filter(search = " ")) - assertEquals(listOf(a.id), got.map { it.id }) - } - - @Test - fun `search survives reopen`() { - val n = note("persistent token", ts = 100) - store.insert(n) - store.close() - - val reopened = FsEventStore(root) - try { - val got = reopened.query(Filter(search = "persistent")) - assertEquals(listOf(n.id), got.map { it.id }) - } finally { - reopened.close() + val got = store.query(Filter(search = "bitcoin")) + assertEquals(listOf(a.id), got.map { it.id }) + } + + @Test + fun `multi-token search is AND across tokens`() = + runBlocking { + val a = note("bitcoin only", ts = 1) + val b = note("nostr only", ts = 2) + val c = note("bitcoin and nostr", ts = 3) + store.insert(a) + store.insert(b) + store.insert(c) + + val got = store.query(Filter(search = "bitcoin nostr")) + assertEquals(listOf(c.id), got.map { it.id }, "AND semantics: only the doc with both tokens matches") + } + + @Test + fun `search results are ordered by createdAt DESC`() = + runBlocking { + val older = note("bitcoin first", ts = 10) + val newer = note("bitcoin again", ts = 20) + store.insert(older) + store.insert(newer) + + val got = store.query(Filter(search = "bitcoin")) + assertEquals(listOf(newer.id, older.id), got.map { it.id }) + } + + @Test + fun `search respects limit`() = + runBlocking { + repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) } + val got = store.query(Filter(search = "bitcoin", limit = 2)) + assertEquals(2, got.size) + } + + @Test + fun `search composes with kinds and authors via post-filter`() = + runBlocking { + val match = note("bitcoin maximalism", ts = 5) + store.insert(match) + + val got = + store.query( + Filter(search = "bitcoin", kinds = listOf(1), authors = listOf(signer.pubKey)), + ) + assertEquals(listOf(match.id), got.map { it.id }) + + val miss = + store.query( + Filter(search = "bitcoin", kinds = listOf(2)), + ) + assertEquals(emptyList(), miss.map { it.id }) + } + + @Test + fun `search with no matching token returns empty`() = + runBlocking { + store.insert(note("nostr only", ts = 1)) + assertEquals( + emptyList(), + store.query(Filter(search = "bitcoin")).map { it.id }, + ) + } + + @Test + fun `blank search string is ignored`() = + runBlocking { + val a = note("anything", ts = 1) + store.insert(a) + // Blank search shouldn't drive by FTS — the planner falls through + // to all-kinds, and the event surfaces. + val got = store.query(Filter(search = " ")) + assertEquals(listOf(a.id), got.map { it.id }) + } + + @Test + fun `search survives reopen`() = + runBlocking { + val n = note("persistent token", ts = 100) + store.insert(n) + store.close() + + val reopened = FsEventStore(root) + try { + val got = reopened.query(Filter(search = "persistent")) + assertEquals(listOf(n.id), got.map { it.id }) + } finally { + reopened.close() + } } - } // ------------------------------------------------------------------ // Maintenance under replaceable / deletion / vanish // ------------------------------------------------------------------ @Test - fun `fts entry is unlinked when event is deleted`() { - val n = note("unique-token-zzz", ts = 1) - store.insert(n) - assertTrue(root.resolve("idx/fts/unique").exists()) - assertTrue(root.resolve("idx/fts/token").exists()) - assertTrue(root.resolve("idx/fts/zzz").exists()) + fun `fts entry is unlinked when event is deleted`() = + runBlocking { + val n = note("unique-token-zzz", ts = 1) + store.insert(n) + assertTrue(root.resolve("idx/fts/unique").exists()) + assertTrue(root.resolve("idx/fts/token").exists()) + assertTrue(root.resolve("idx/fts/zzz").exists()) - store.delete(n.id) - assertFalse( - root.resolve("idx/fts/zzz").let { it.exists() && it.listDirectoryEntries().isNotEmpty() }, - "zzz token entry should be unlinked", - ) + store.delete(n.id) + assertFalse( + root.resolve("idx/fts/zzz").let { it.exists() && it.listDirectoryEntries().isNotEmpty() }, + "zzz token entry should be unlinked", + ) - // And a search no longer finds it. - assertEquals(emptyList(), store.query(Filter(search = "zzz")).map { it.id }) - } + // And a search no longer finds it. + assertEquals(emptyList(), store.query(Filter(search = "zzz")).map { it.id }) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt index fd8eedeb0..8712fe8ce 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -72,171 +73,181 @@ class FsSlotsTest { ) @Test - fun `newer replaceable evicts older`() { - val v1 = metadata("old", 100) - val v2 = metadata("new", 200) - store.insert(v1) - store.insert(v2) + fun `newer replaceable evicts older`() = + runBlocking { + val v1 = metadata("old", 100) + val v2 = metadata("new", 200) + store.insert(v1) + store.insert(v2) - // Only the newer survives a query by author. - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(listOf(v2.id), got.map { it.id }) + // Only the newer survives a query by author. + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(listOf(v2.id), got.map { it.id }) - // The older canonical is gone. - assertFalse(store.hasCanonical(v1.id), "older canonical should be removed") - } + // The older canonical is gone. + assertFalse(store.hasCanonical(v1.id), "older canonical should be removed") + } @Test - fun `older replaceable is rejected when newer exists`() { - val newer = metadata("new", 200) - val older = metadata("old", 100) - store.insert(newer) - store.insert(older) + fun `older replaceable is rejected when newer exists`() = + runBlocking { + val newer = metadata("new", 200) + val older = metadata("old", 100) + store.insert(newer) + store.insert(older) - // Newer still wins. - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(listOf(newer.id), got.map { it.id }) + // Newer still wins. + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(listOf(newer.id), got.map { it.id }) - // And the older was never persisted. - assertFalse(store.hasCanonical(older.id), "older should have been rejected") - } + // And the older was never persisted. + assertFalse(store.hasCanonical(older.id), "older should have been rejected") + } @Test - fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() { - val a = metadata("a", 100) - val b = metadata("b", 100) - // NIP-01 tiebreaker: when createdAt ties, the lexically smaller - // id wins, regardless of insertion order. - val (winner, loser) = if (a.id < b.id) a to b else b to a + fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() = + runBlocking { + val a = metadata("a", 100) + val b = metadata("b", 100) + // NIP-01 tiebreaker: when createdAt ties, the lexically smaller + // id wins, regardless of insertion order. + val (winner, loser) = if (a.id < b.id) a to b else b to a - // Loser inserted first, then winner — winner must replace. - store.insert(loser) - store.insert(winner) + // Loser inserted first, then winner — winner must replace. + store.insert(loser) + store.insert(winner) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(1, got.size) - assertEquals(winner.id, got.single().id) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(1, got.size) + assertEquals(winner.id, got.single().id) + } @Test - fun `equal timestamp replaceable rejects higher id when winner already present`() { - val a = metadata("a", 100) - val b = metadata("b", 100) - val (winner, loser) = if (a.id < b.id) a to b else b to a + fun `equal timestamp replaceable rejects higher id when winner already present`() = + runBlocking { + val a = metadata("a", 100) + val b = metadata("b", 100) + val (winner, loser) = if (a.id < b.id) a to b else b to a - // Winner inserted first — loser must NOT take the slot. - store.insert(winner) - store.insert(loser) + // Winner inserted first — loser must NOT take the slot. + store.insert(winner) + store.insert(loser) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(1, got.size) - assertEquals(winner.id, got.single().id) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(1, got.size) + assertEquals(winner.id, got.single().id) + } @Test - fun `replaceable slot file contains the current winner`() { - val v = metadata("only", 100) - store.insert(v) + fun `replaceable slot file contains the current winner`() = + runBlocking { + val v = metadata("only", 100) + store.insert(v) - val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") - assertTrue(slot.exists(), "slot must exist") - val parsed = Event.fromJson(slot.readText()) - assertEquals(v.id, parsed.id) - } + val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") + assertTrue(slot.exists(), "slot must exist") + val parsed = Event.fromJson(slot.readText()) + assertEquals(v.id, parsed.id) + } @Test - fun `replaceable slot survives canonical deletion via hardlink`() { - val v = metadata("x", 100) - store.insert(v) + fun `replaceable slot survives canonical deletion via hardlink`() = + runBlocking { + val v = metadata("x", 100) + store.insert(v) - // Simulate a user (or bug) removing the canonical file. - val canonical = - root - .resolve("events") - .resolve(v.id.substring(0, 2)) - .resolve(v.id.substring(2, 4)) - .resolve("${v.id}.json") - assertTrue(Files.deleteIfExists(canonical)) + // Simulate a user (or bug) removing the canonical file. + val canonical = + root + .resolve("events") + .resolve(v.id.substring(0, 2)) + .resolve(v.id.substring(2, 4)) + .resolve("${v.id}.json") + assertTrue(Files.deleteIfExists(canonical)) - val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") - assertTrue(slot.exists(), "slot should persist even if canonical is gone (hardlink to same inode)") - val parsed = Event.fromJson(slot.readText()) - assertEquals(v.id, parsed.id) - } + val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") + assertTrue(slot.exists(), "slot should persist even if canonical is gone (hardlink to same inode)") + val parsed = Event.fromJson(slot.readText()) + assertEquals(v.id, parsed.id) + } @Test - fun `eviction unlinks index hardlinks for the old winner`() { - val v1 = metadata("old", 100) - val v2 = metadata("new", 200) - store.insert(v1) - store.insert(v2) + fun `eviction unlinks index hardlinks for the old winner`() = + runBlocking { + val v1 = metadata("old", 100) + val v2 = metadata("new", 200) + store.insert(v1) + store.insert(v2) - // Author index should have exactly one entry — the winner. - val authorDir = root.resolve("idx/author/${signer.pubKey}") - val entries = - Files.list(authorDir).use { s -> - s.toList().map { it.fileName.toString() } - } - assertEquals(1, entries.size, "author index should only hold the winner") - assertTrue(entries.single().endsWith("-${v2.id}"), "author index entry must point at winner") - } - - @Test - fun `slot shortcut serves replaceable queries even when idx is wiped`() { - // Belt-and-suspenders for the planner shortcut: a query pinned to - // (kinds=[0], authors=[pk]) must hit the slot directly without - // touching idx/. Wipe idx/ to prove the shortcut isn't relying on - // it. - val v = metadata("p", 100) - store.insert(v) - java.nio.file.Files - .walk(root.resolve("idx")) - .use { s -> - s.sorted(Comparator.reverseOrder()).forEach { - java.nio.file.Files - .deleteIfExists(it) + // Author index should have exactly one entry — the winner. + val authorDir = root.resolve("idx/author/${signer.pubKey}") + val entries = + Files.list(authorDir).use { s -> + s.toList().map { it.fileName.toString() } } - } - - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) - assertEquals(listOf(v.id), got.map { it.id }, "slot shortcut should serve from replaceable/, not idx/") - } + assertEquals(1, entries.size, "author index should only hold the winner") + assertTrue(entries.single().endsWith("-${v2.id}"), "author index entry must point at winner") + } @Test - fun `slot shortcut serves addressable queries when d-tag supplied`() { - val v = article("intro", "v", 10) - store.insert(v) - java.nio.file.Files - .walk(root.resolve("idx")) - .use { s -> - s.sorted(Comparator.reverseOrder()).forEach { - java.nio.file.Files - .deleteIfExists(it) + fun `slot shortcut serves replaceable queries even when idx is wiped`() = + runBlocking { + // Belt-and-suspenders for the planner shortcut: a query pinned to + // (kinds=[0], authors=[pk]) must hit the slot directly without + // touching idx/. Wipe idx/ to prove the shortcut isn't relying on + // it. + val v = metadata("p", 100) + store.insert(v) + java.nio.file.Files + .walk(root.resolve("idx")) + .use { s -> + s.sorted(Comparator.reverseOrder()).forEach { + java.nio.file.Files + .deleteIfExists(it) + } } - } - val got = - store.query( - Filter( - authors = listOf(signer.pubKey), - kinds = listOf(LongTextNoteEvent.KIND), - tags = mapOf("d" to listOf("intro")), - ), - ) - assertEquals(listOf(v.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) + assertEquals(listOf(v.id), got.map { it.id }, "slot shortcut should serve from replaceable/, not idx/") + } @Test - fun `delete of current replaceable winner clears the slot`() { - val v = metadata("only", 100) - store.insert(v) + fun `slot shortcut serves addressable queries when d-tag supplied`() = + runBlocking { + val v = article("intro", "v", 10) + store.insert(v) + java.nio.file.Files + .walk(root.resolve("idx")) + .use { s -> + s.sorted(Comparator.reverseOrder()).forEach { + java.nio.file.Files + .deleteIfExists(it) + } + } - val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") - assertTrue(slot.exists()) + val got = + store.query( + Filter( + authors = listOf(signer.pubKey), + kinds = listOf(LongTextNoteEvent.KIND), + tags = mapOf("d" to listOf("intro")), + ), + ) + assertEquals(listOf(v.id), got.map { it.id }) + } - store.delete(v.id) - assertFalse(slot.exists(), "slot should be cleared when winner is deleted") - } + @Test + fun `delete of current replaceable winner clears the slot`() = + runBlocking { + val v = metadata("only", 100) + store.insert(v) + + val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") + assertTrue(slot.exists()) + + store.delete(v.id) + assertFalse(slot.exists(), "slot should be cleared when winner is deleted") + } // ------------------------------------------------------------------ // Addressable (kinds 30000-39999) @@ -255,100 +266,107 @@ class FsSlotsTest { ) @Test - fun `newer addressable evicts older for same d-tag`() { - val v1 = article("intro", "draft 1", 10) - val v2 = article("intro", "draft 2", 20) - store.insert(v1) - store.insert(v2) + fun `newer addressable evicts older for same d-tag`() = + runBlocking { + val v1 = article("intro", "draft 1", 10) + val v2 = article("intro", "draft 2", 20) + store.insert(v1) + store.insert(v2) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - assertEquals(listOf(v2.id), got.map { it.id }) - assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed") - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertEquals(listOf(v2.id), got.map { it.id }) + assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed") + } @Test - fun `addressable with different d-tags coexist`() { - val intro = article("intro", "hello", 10) - val about = article("about", "bio", 15) - store.insert(intro) - store.insert(about) + fun `addressable with different d-tags coexist`() = + runBlocking { + val intro = article("intro", "hello", 10) + val about = article("about", "bio", 15) + store.insert(intro) + store.insert(about) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) - assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet()) - } + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet()) + } @Test - fun `older addressable is rejected when newer exists`() { - val newer = article("slug", "new", 200) - val older = article("slug", "old", 100) - store.insert(newer) - store.insert(older) + fun `older addressable is rejected when newer exists`() = + runBlocking { + val newer = article("slug", "new", 200) + val older = article("slug", "old", 100) + store.insert(newer) + store.insert(older) - val got = store.query(Filter(authors = listOf(signer.pubKey))) - assertEquals(listOf(newer.id), got.map { it.id }) - } + val got = store.query(Filter(authors = listOf(signer.pubKey))) + assertEquals(listOf(newer.id), got.map { it.id }) + } @Test - fun `addressable slot file contains the current winner`() { - val v = article("intro", "hello", 10) - store.insert(v) + fun `addressable slot file contains the current winner`() = + runBlocking { + val v = article("intro", "hello", 10) + store.insert(v) - val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertTrue(slot.exists()) - val parsed = Event.fromJson(slot.readText()) - assertEquals(v.id, parsed.id) - } + val dHash = FsLayout.sha256Hex("intro") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertTrue(slot.exists()) + val parsed = Event.fromJson(slot.readText()) + assertEquals(v.id, parsed.id) + } @Test - fun `empty d-tag gets its own slot`() { - val v = article("", "homepage", 1) - store.insert(v) + fun `empty d-tag gets its own slot`() = + runBlocking { + val v = article("", "homepage", 1) + store.insert(v) - val dHash = FsLayout.sha256Hex("") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertTrue(slot.exists()) - } + val dHash = FsLayout.sha256Hex("") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertTrue(slot.exists()) + } @Test - fun `delete of current addressable winner clears the slot`() { - val v = article("intro", "hello", 10) - store.insert(v) - val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") - assertTrue(slot.exists()) + fun `delete of current addressable winner clears the slot`() = + runBlocking { + val v = article("intro", "hello", 10) + store.insert(v) + val dHash = FsLayout.sha256Hex("intro") + val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + assertTrue(slot.exists()) - store.delete(v.id) - assertFalse(slot.exists()) - } + store.delete(v.id) + assertFalse(slot.exists()) + } // ------------------------------------------------------------------ // Non-replaceable events: no slot involvement // ------------------------------------------------------------------ @Test - fun `regular text note has no slot`() { - val note = - signer.sign( - createdAt = 1, - kind = 1, - tags = emptyArray(), - content = "plain", - ) - store.insert(note) + fun `regular text note has no slot`() = + runBlocking { + val note = + signer.sign( + createdAt = 1, + kind = 1, + tags = emptyArray(), + content = "plain", + ) + store.insert(note) - // No entries under replaceable/ or addressable/ — only the scaffolded dirs exist. - val replaceableDir = root.resolve("replaceable") - val addressableDir = root.resolve("addressable") - assertEquals( - 0, - Files.walk(replaceableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, - ) - assertEquals( - 0, - Files.walk(addressableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, - ) - } + // No entries under replaceable/ or addressable/ — only the scaffolded dirs exist. + val replaceableDir = root.resolve("replaceable") + val addressableDir = root.resolve("addressable") + assertEquals( + 0, + Files.walk(replaceableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, + ) + assertEquals( + 0, + Files.walk(addressableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, + ) + } // helper — check canonical existence private fun FsEventStore.hasCanonical(id: String): Boolean { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt index 0e824a865..18703ed43 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.exists @@ -81,171 +82,182 @@ class FsVanishTest { // ------------------------------------------------------------------ @Test - fun `vanish for this relay cascades older events from the same author`() { - val n1 = note("a", 10) - val n2 = note("b", 20) - val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict < - store.insert(n1) - store.insert(n2) - store.insert(n3) + fun `vanish for this relay cascades older events from the same author`() = + runBlocking { + val n1 = note("a", 10) + val n2 = note("b", 20) + val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict < + store.insert(n1) + store.insert(n2) + store.insert(n3) - val v = vanish(ts = 30) - store.insert(v) + val v = vanish(ts = 30) + store.insert(v) - assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted") - assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted") - assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives") - assertTrue(store.hasCanonical(v.id)) - } - - @Test - fun `vanish for a different relay does NOT cascade`() { - val n = note("x", 10) - store.insert(n) - - val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example") - store.insert(v) - - assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade") - // The kind-62 event itself is still persisted (it's just a normal event). - assertTrue(store.hasCanonical(v.id)) - // No vanish tombstone installed. - val tombDir = root.resolve("tombstones/vanish") - if (tombDir.exists()) { - assertEquals(0, Files.list(tombDir).use { it.toList() }.size) + assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted") + assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted") + assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives") + assertTrue(store.hasCanonical(v.id)) } - } @Test - fun `vanishFromEverywhere always cascades regardless of relay`() { - val n = note("x", 10) - store.insert(n) + fun `vanish for a different relay does NOT cascade`() = + runBlocking { + val n = note("x", 10) + store.insert(n) - val v = vanishEverywhere(ts = 20) - store.insert(v) + val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example") + store.insert(v) - assertFalse(store.hasCanonical(n.id)) - } + assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade") + // The kind-62 event itself is still persisted (it's just a normal event). + assertTrue(store.hasCanonical(v.id)) + // No vanish tombstone installed. + val tombDir = root.resolve("tombstones/vanish") + if (tombDir.exists()) { + assertEquals(0, Files.list(tombDir).use { it.toList() }.size) + } + } + + @Test + fun `vanishFromEverywhere always cascades regardless of relay`() = + runBlocking { + val n = note("x", 10) + store.insert(n) + + val v = vanishEverywhere(ts = 20) + store.insert(v) + + assertFalse(store.hasCanonical(n.id)) + } // ------------------------------------------------------------------ // Block re-insert // ------------------------------------------------------------------ @Test - fun `events older than vanish are blocked from re-insertion`() { - val n = note("a", 10) - store.insert(n) + fun `events older than vanish are blocked from re-insertion`() = + runBlocking { + val n = note("a", 10) + store.insert(n) - val v = vanish(ts = 50) - store.insert(v) + val v = vanish(ts = 50) + store.insert(v) - // Re-insert blocked. - store.insert(n) - assertFalse(store.hasCanonical(n.id)) + // Re-insert blocked. + store.insert(n) + assertFalse(store.hasCanonical(n.id)) - // A brand-new older event by the same author also blocked. - val older = note("older", 5) - store.insert(older) - assertFalse(store.hasCanonical(older.id)) - } + // A brand-new older event by the same author also blocked. + val older = note("older", 5) + store.insert(older) + assertFalse(store.hasCanonical(older.id)) + } @Test - fun `events at vanish ts are blocked, parity with SQLite`() { - val v = vanish(ts = 50) - store.insert(v) + fun `events at vanish ts are blocked, parity with SQLite`() = + runBlocking { + val v = vanish(ts = 50) + store.insert(v) - val equal = note("equal", 50) - store.insert(equal) - assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked") - } + val equal = note("equal", 50) + store.insert(equal) + assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked") + } @Test - fun `events newer than vanish still pass`() { - val v = vanish(ts = 50) - store.insert(v) + fun `events newer than vanish still pass`() = + runBlocking { + val v = vanish(ts = 50) + store.insert(v) - val newer = note("newer", 100) - store.insert(newer) - assertTrue(store.hasCanonical(newer.id)) - } + val newer = note("newer", 100) + store.insert(newer) + assertTrue(store.hasCanonical(newer.id)) + } @Test - fun `another author is unaffected by my vanish`() { - val mine = note("mine", 10) - store.insert(mine) - val theirs = note("theirs", 5, s = otherSigner) - store.insert(theirs) + fun `another author is unaffected by my vanish`() = + runBlocking { + val mine = note("mine", 10) + store.insert(mine) + val theirs = note("theirs", 5, s = otherSigner) + store.insert(theirs) - val v = vanish(ts = 50) - store.insert(v) + val v = vanish(ts = 50) + store.insert(v) - assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted") - assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected") - } + assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted") + assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected") + } // ------------------------------------------------------------------ // Multiple vanish requests — strongest cutoff wins // ------------------------------------------------------------------ @Test - fun `later vanish raises the cutoff`() { - val n100 = note("at-100", 100) - store.insert(n100) - store.insert(vanish(ts = 50)) - // n100 still around because 100 > 50. - assertTrue(store.hasCanonical(n100.id)) + fun `later vanish raises the cutoff`() = + runBlocking { + val n100 = note("at-100", 100) + store.insert(n100) + store.insert(vanish(ts = 50)) + // n100 still around because 100 > 50. + assertTrue(store.hasCanonical(n100.id)) - // Stronger vanish at ts=200 cascades it. - store.insert(vanish(ts = 200)) - assertFalse(store.hasCanonical(n100.id)) + // Stronger vanish at ts=200 cascades it. + store.insert(vanish(ts = 200)) + assertFalse(store.hasCanonical(n100.id)) - // And new events at ts=150 are now blocked. - val mid = note("mid", 150) - store.insert(mid) - assertFalse(store.hasCanonical(mid.id)) - } + // And new events at ts=150 are now blocked. + val mid = note("mid", 150) + store.insert(mid) + assertFalse(store.hasCanonical(mid.id)) + } @Test - fun `earlier vanish does not lower a stronger cutoff`() { - store.insert(vanish(ts = 200)) - store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone + fun `earlier vanish does not lower a stronger cutoff`() = + runBlocking { + store.insert(vanish(ts = 200)) + store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone - val mid = note("mid", 150) - store.insert(mid) - assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200") - } + val mid = note("mid", 150) + store.insert(mid) + assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200") + } // ------------------------------------------------------------------ // Tombstone is a hardlink to the kind-62 event // ------------------------------------------------------------------ @Test - fun `vanish tombstone shares an inode with the kind-62 event`() { - val v = vanish(ts = 30) - store.insert(v) + fun `vanish tombstone shares an inode with the kind-62 event`() = + runBlocking { + val v = vanish(ts = 30) + store.insert(v) - val tombDir = root.resolve("tombstones/vanish") - val entries = Files.list(tombDir).use { it.toList() } - assertEquals(1, entries.size) + val tombDir = root.resolve("tombstones/vanish") + val entries = Files.list(tombDir).use { it.toList() } + assertEquals(1, entries.size) - val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json") - val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() - val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() - assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical") - } + val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json") + val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() + val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() + assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical") + } // ------------------------------------------------------------------ // Vanish event itself remains queryable // ------------------------------------------------------------------ @Test - fun `vanish event itself is indexed and queryable`() { - val v = vanish(ts = 30) - store.insert(v) + fun `vanish event itself is indexed and queryable`() = + runBlocking { + val v = vanish(ts = 30) + store.insert(v) - val byKind = store.query(Filter(kinds = listOf(RequestToVanishEvent.KIND))) - assertEquals(listOf(v.id), byKind.map { it.id }) - } + val byKind = store.query(Filter(kinds = listOf(RequestToVanishEvent.KIND))) + assertEquals(listOf(v.id), byKind.map { it.id }) + } private fun FsEventStore.hasCanonical(id: String): Boolean { val p = diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt new file mode 100644 index 000000000..eeff076f9 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ParallelInsertTest.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Stress test for the SQLite connection pool. Pre-pool, two coroutines + * inserting at the same time would race on the shared `SQLiteConnection` + * (`androidx.sqlite` connections aren't thread-safe) and crash with + * either `SQLITE_ERROR: cannot start a transaction within a transaction` + * or a corrupted prepared statement (`SQLITE_MISUSE`). + * + * With [SQLiteConnectionPool] writes serialise behind a coroutine `Mutex` + * and reads run in parallel against a fixed pool of reader connections, + * matching what Room does. The test launches a fan-out of inserts and + * concurrent reads, then asserts every inserted event is visible and the + * count is exact. + */ +class ParallelInsertTest { + private val signer = NostrSignerSync() + private lateinit var dbFile: Path + private lateinit var store: EventStore + + @BeforeTest + fun setup() { + Secp256k1Instance + // Use a real file so the pool can hand out independent reader + // connections — :memory: would make every connection a separate DB. + dbFile = Files.createTempFile("parallel-insert-", ".db") + // Driver expects to open the file itself; ensure the placeholder + // is gone so SQLite can create a fresh DB. + Files.deleteIfExists(dbFile) + store = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null) + } + + @AfterTest + fun tearDown() { + store.close() + // SQLite leaves -wal / -shm sidecars next to the main file under WAL. + listOf("", "-wal", "-shm", "-journal").forEach { suffix -> + Path.of(dbFile.toString() + suffix).deleteIfExists() + } + } + + @Test + fun `parallel inserts on N coroutines all succeed`() = + runBlocking { + val perCoroutine = 200 + val coroutines = 8 + val total = perCoroutine * coroutines + + val events = + (0 until total).map { i -> + signer.sign(TextNoteEvent.build("p$i", createdAt = i.toLong() + 1)) + } + + // Fan out inserts across `coroutines` workers on the IO + // dispatcher (multi-thread). Without the pool's writer mutex + // these all race on a single SQLiteConnection and crash. + coroutineScope { + events.chunked(perCoroutine).forEach { chunk -> + launch(Dispatchers.IO) { + for (e in chunk) store.insert(e) + } + } + } + + assertEquals(total, store.count(Filter()), "every insert must be visible") + + val byId = store.query(Filter()).associateBy { it.id } + for (e in events) { + assertTrue(byId.containsKey(e.id), "missing event ${e.id.take(8)}") + } + } + + @Test + fun `parallel reads run alongside writes without crashing`() = + runBlocking { + val writes = 500 + + val events = + (0 until writes).map { i -> + signer.sign(TextNoteEvent.build("rw$i", createdAt = i.toLong() + 1)) + } + + coroutineScope { + // Writer feed. + launch(Dispatchers.IO) { + for (e in events) store.insert(e) + } + // Multiple reader fans-out: count() and query() running + // continuously while inserts are still in flight. Asserts + // none of these crash with SQLITE_MISUSE. + val readers = + List(4) { + async(Dispatchers.IO) { + var lastSeen = 0 + repeat(100) { + val n = store.count(Filter()) + assertTrue(n in 0..writes) + if (n > lastSeen) lastSeen = n + } + lastSeen + } + } + readers.awaitAll() + } + + assertEquals(writes, store.count(Filter())) + } + + @Test + fun `parallel transaction batches all commit`() = + runBlocking { + val batches = 8 + val perBatch = 50 + val total = batches * perBatch + + val events = + (0 until total).map { i -> + signer.sign(TextNoteEvent.build("t$i", createdAt = i.toLong() + 1)) + } + + // Each coroutine wraps its slice in store.transaction { ... }, + // exercising the writer mutex around BEGIN/COMMIT pairs. + coroutineScope { + events.chunked(perBatch).forEach { chunk -> + launch(Dispatchers.IO) { + store.transaction { + for (e in chunk) insert(e) + } + } + } + } + + assertEquals(total, store.count(Filter())) + } + + @Test + fun `pool with file-backed db survives reopen`() = + runBlocking { + // Smoke test that the pool migration runs idempotently when + // a writer connection is reopened against an existing DB. + val first = signer.sign(TextNoteEvent.build("first", createdAt = 1)) + store.insert(first) + store.close() + + val reopened = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null) + try { + assertTrue(dbFile.exists()) + val got = reopened.query(Filter(ids = listOf(first.id))) + assertEquals(listOf(first.id), got.map { it.id }) + + // And then more parallel inserts still work on the + // reopened pool. + val moreCount = 20 + val more = (0 until moreCount).map { signer.sign(TextNoteEvent.build("m$it", createdAt = it.toLong() + 100)) } + coroutineScope { + more.forEach { e -> + launch(Dispatchers.IO) { reopened.insert(e) } + } + } + assertEquals(1 + moreCount, reopened.count(Filter())) + } finally { + reopened.close() + } + } +} diff --git a/scripts/relax-deb-libicu.sh b/scripts/relax-deb-libicu.sh new file mode 100755 index 000000000..27f96c8f2 --- /dev/null +++ b/scripts/relax-deb-libicu.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Broaden the libicu Depends clause in a jpackage-built .deb so it installs +# across Debian/Ubuntu releases. +# +# jpackage shells out to `dpkg-shlibdeps` against the bundled JDK runtime's +# native libraries (libfontmanager.so, etc., which link libicu). That pins the +# Depends to whatever libicu the build host ships — libicu74 on ubuntu-24.04 — +# even though the bundled JRE works fine against any reasonably recent ICU. +# Without this rewrite, users on Ubuntu 22.04 (libicu70), Debian 12 (libicu72), +# Debian 11 (libicu67), or Debian 13 (libicu76) cannot install the package. +# +# Neither jpackage nor the Compose Multiplatform DSL exposes a way to override +# the auto-generated Depends, so we rewrite the .deb after the fact. +# +# Usage: relax-deb-libicu.sh [ ...] +set -euo pipefail + +# Spans Debian 11 → 13 and Ubuntu 20.04 → 26.04. Append new SONAMEs here when +# a new Debian/Ubuntu release ships a bumped libicu. +ALT='libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77' + +for deb in "$@"; do + if [[ ! -f "$deb" ]]; then + echo "skip: not a file: $deb" >&2 + continue + fi + + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT + dpkg-deb -R "$deb" "$work/pkg" + control="$work/pkg/DEBIAN/control" + + if grep -qE 'libicu[0-9]+' "$control"; then + sed -i -E "s/libicu[0-9]+([[:space:]]*\\|[[:space:]]*libicu[0-9]+)*/${ALT}/g" "$control" + dpkg-deb --root-owner-group -Zxz -b "$work/pkg" "$deb" >/dev/null + echo "Relaxed libicu dep: $deb" + else + echo "No libicu dep, leaving as-is: $deb" + fi + + rm -rf "$work" + trap - EXIT +done