mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18b7f770f | ||
|
|
826fc826db | ||
|
|
6cf09f0d75 | ||
|
|
9ea7c36cf9 | ||
|
|
69532badee | ||
|
|
a1f9cc96f1 | ||
|
|
a0a55af174 | ||
|
|
defbdfbb28 | ||
|
|
af49bcc396 | ||
|
|
cbd0a4a174 | ||
|
|
34c60fada1 | ||
|
|
0030432e20 | ||
|
|
36819b1011 | ||
|
|
985b4c2ea3 | ||
|
|
c25517c2d6 | ||
|
|
790458f9a0 | ||
|
|
6f71eb0c4c | ||
|
|
04d506e81e | ||
|
|
b10be95a6e | ||
|
|
514f4b26f0 | ||
|
|
91d4d66461 | ||
|
|
8a43d707e6 | ||
|
|
de3ce56bcc | ||
|
|
2aa4a43337 | ||
|
|
2415565ebf | ||
|
|
a370b1d8c5 | ||
|
|
e38981fd1d | ||
|
|
7ff8e3b5ac |
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 android.content.Context
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Shared harness for the MediaSaverToDisk instrumented tests: writes a small payload
|
||||
* file, drives [MediaSaverToDisk.save] with the given MIME type, and asserts the save
|
||||
* reported success. Package-level support object per the AvifInstrumentedTestSupport
|
||||
* precedent.
|
||||
*/
|
||||
object MediaSaverTestSupport {
|
||||
/** Drives one save and fails the test if it reported an error or never succeeded. */
|
||||
fun saveAndAssertSuccess(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
) {
|
||||
val localFile = File(context.cacheDir, "media-saver-${UUID.randomUUID()}.bin")
|
||||
localFile.writeBytes(ByteArray(2048) { it.toByte() })
|
||||
|
||||
var failure: Throwable? = null
|
||||
var succeeded = false
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
MediaSaverToDisk.save(
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
context = context,
|
||||
onSuccess = { succeeded = true },
|
||||
onError = { failure = it },
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
localFile.delete()
|
||||
}
|
||||
|
||||
// Surfaces e.g. the #4009 IllegalArgumentException as the test failure message.
|
||||
assertNull("save() reported an error: ${failure?.message}", failure)
|
||||
assertTrue("save() never reported success", succeeded)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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 android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Covers the pre-Q writer, which MediaStore never sees: below API 29 saveContentDefault
|
||||
* writes straight to a public directory and lets the media scanner index it.
|
||||
*
|
||||
* That path used to hardcode Pictures for every content type, so videos, audio and PDFs
|
||||
* were all filed under Pictures/Amethyst. It now routes through the same MediaStoreTarget
|
||||
* as the MediaStore path. minSdk is 26, so this range ships.
|
||||
*
|
||||
* There is no JVM coverage of any of this: Build.VERSION.SDK_INT is 0 under
|
||||
* returnDefaultValues, so unit tests can only reach the routing function, never the writer.
|
||||
*
|
||||
* **Running this suite:** below Q the storage grant must exist before the app process
|
||||
* forks (external storage is mounted at fork time), and Gradle's connectedAndroidTest
|
||||
* installs and instruments with no window to grant in between - so these tests skip
|
||||
* under it. Drive them manually on an API 26-28 device:
|
||||
* ```
|
||||
* ./gradlew :amethyst:assemblePlayDebug :amethyst:assemblePlayDebugAndroidTest
|
||||
* adb install -r -g amethyst/build/outputs/apk/play/debug/amethyst-play-arm64-v8a-debug.apk
|
||||
* adb install -r -g amethyst/build/outputs/apk/androidTest/play/debug/amethyst-play-debug-androidTest.apk
|
||||
* adb shell am instrument -w -e class com.vitorpamplona.amethyst.ui.actions.MediaSaverToDiskLegacyStorageTest \
|
||||
* com.vitorpamplona.amethyst.debug.test/androidx.test.runner.AndroidJUnitRunner
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaSaverToDiskLegacyStorageTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** Every directory production can write to, straight from the routing table. */
|
||||
private val watchedDirs = MediaSaverToDisk.MediaStoreTarget.entries.map { it.relativeDirectory }
|
||||
private val createdFiles = mutableListOf<File>()
|
||||
|
||||
@Before
|
||||
fun onlyBelowScopedStorage() {
|
||||
assumeTrue("saveContentDefault only runs below API 29", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
|
||||
|
||||
// The legacy writer needs the runtime permission; no androidx.test:rules on the
|
||||
// classpath, so grant it through the instrumentation shell instead. The output has
|
||||
// to be drained: executeShellCommand runs asynchronously and closing the descriptor
|
||||
// early kills the command before it applies.
|
||||
val fd =
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.uiAutomation
|
||||
.executeShellCommand(
|
||||
"pm grant ${context.packageName} android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
)
|
||||
ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() }
|
||||
|
||||
assertEquals(
|
||||
"WRITE_EXTERNAL_STORAGE was not granted; the legacy writer cannot be exercised",
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
)
|
||||
|
||||
// Holding the permission is not enough below Q: external storage is mounted into
|
||||
// the process when it forks, so a grant to an already-running process never
|
||||
// reaches it and every write fails with EACCES. Probe for real writability and
|
||||
// skip rather than report a routing failure that is really a harness problem.
|
||||
assumeTrue(
|
||||
"External storage is not writable by this process; below API 29 the grant must " +
|
||||
"exist at install time. See this class's KDoc for the exact run recipe.",
|
||||
canWriteToPublicStorage(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun canWriteToPublicStorage(): Boolean =
|
||||
try {
|
||||
val dir = amethystDir("Movies").apply { if (!exists()) mkdirs() }
|
||||
val probe = File(dir, ".write-probe-${System.nanoTime()}")
|
||||
val writable = probe.createNewFile()
|
||||
probe.delete()
|
||||
writable
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
createdFiles.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoGoesToMovies() = assertRoutes("video/mp4", "Movies")
|
||||
|
||||
@Test
|
||||
fun imageGoesToPictures() = assertRoutes("image/jpeg", "Pictures")
|
||||
|
||||
@Test
|
||||
fun audioGoesToMusic() = assertRoutes("audio/mpeg", "Music")
|
||||
|
||||
@Test
|
||||
fun pdfGoesToDownloads() = assertRoutes("application/pdf", "Download")
|
||||
|
||||
/**
|
||||
* Saves one file and asserts it appeared under [expectedDir]/Amethyst and nowhere else.
|
||||
* Checking the other directories is the point: the bug was everything landing in Pictures.
|
||||
*/
|
||||
private fun assertRoutes(
|
||||
mimeType: String,
|
||||
expectedDir: String,
|
||||
) {
|
||||
val before = snapshot()
|
||||
|
||||
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
|
||||
|
||||
val added = snapshot().mapValues { (dir, names) -> names - before.getValue(dir) }
|
||||
added.forEach { (dir, names) -> names.forEach { createdFiles.add(File(amethystDir(dir), it)) } }
|
||||
|
||||
val dirsThatGrew = added.filterValues { it.isNotEmpty() }.keys
|
||||
assertEquals("$mimeType should land only in $expectedDir/Amethyst", setOf(expectedDir), dirsThatGrew)
|
||||
assertEquals("expected exactly one new file", 1, added.getValue(expectedDir).size)
|
||||
}
|
||||
|
||||
private fun amethystDir(publicDir: String) = File(Environment.getExternalStoragePublicDirectory(publicDir), "Amethyst")
|
||||
|
||||
private fun snapshot(): Map<String, Set<String>> = watchedDirs.associateWith { amethystDir(it).list()?.toSet() ?: emptySet() }
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* End-to-end regression test for issue #4009: drives the real ContentResolver, so it
|
||||
* catches both symptoms of a collection/directory mismatch - Android 10 rejects the
|
||||
* insert outright (the quoted rejection lives in [MediaSaverToDisk.MediaStoreTarget]'s
|
||||
* KDoc), and later releases accept it and silently misfile the video.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaSaverToDiskMediaStoreTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val resolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
/** Only rows this test inserted, as item Uris in the collection they went into. */
|
||||
private val created = mutableListOf<Uri>()
|
||||
|
||||
@Before
|
||||
fun requiresScopedStorage() {
|
||||
assumeTrue("saveContentQ only runs on API 29+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
created.forEach { resolver.delete(it, null, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAVideoLandsInMoviesAndNotPictures() {
|
||||
val relativePath = saveAndReadBackRelativePath("video/mp4", MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
|
||||
|
||||
assertEquals("Movies/Amethyst/", relativePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAnImageStillLandsInPictures() {
|
||||
val relativePath = saveAndReadBackRelativePath("image/jpeg", MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
|
||||
assertEquals("Pictures/Amethyst/", relativePath)
|
||||
}
|
||||
|
||||
private fun saveAndReadBackRelativePath(
|
||||
mimeType: String,
|
||||
collection: Uri,
|
||||
): String? {
|
||||
// Anything at or below this id predates the test and must never be read or deleted:
|
||||
// this suite is meant to be runnable on a real device holding real media.
|
||||
val highWaterMark = maxIdIn(collection)
|
||||
|
||||
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
|
||||
|
||||
return rowInsertedAfter(collection, highWaterMark)
|
||||
}
|
||||
|
||||
private fun maxIdIn(collection: Uri): Long {
|
||||
resolver
|
||||
.query(collection, arrayOf(MediaStore.MediaColumns._ID), null, null, "${MediaStore.MediaColumns._ID} DESC")
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) return cursor.getLong(0)
|
||||
}
|
||||
return -1L
|
||||
}
|
||||
|
||||
/** Reads back the row the save just inserted and records it for cleanup. */
|
||||
private fun rowInsertedAfter(
|
||||
collection: Uri,
|
||||
highWaterMark: Long,
|
||||
): String? {
|
||||
resolver
|
||||
.query(
|
||||
collection,
|
||||
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.RELATIVE_PATH),
|
||||
"${MediaStore.MediaColumns._ID} > ?",
|
||||
arrayOf(highWaterMark.toString()),
|
||||
"${MediaStore.MediaColumns._ID} ASC",
|
||||
)?.use { cursor ->
|
||||
assertTrue("save() reported success but inserted no row into $collection", cursor.moveToFirst())
|
||||
created.add(ContentUris.withAppendedId(collection, cursor.getLong(0)))
|
||||
return cursor.getString(1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 android.os.Environment
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* [MediaStoreTarget] spells its directories out as literals because Environment's
|
||||
* DIRECTORY_* fields are plain statics that the unit-test android.jar leaves null.
|
||||
* This is the other half of that trade: on a real device the literals are checked
|
||||
* against the platform constants they stand in for.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaStoreTargetInstrumentedTest {
|
||||
@Test
|
||||
fun directoriesMatchThePlatformConstants() {
|
||||
assertEquals(Environment.DIRECTORY_PICTURES, MediaStoreTarget.IMAGES.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_MUSIC, MediaStoreTarget.AUDIO.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_MOVIES, MediaStoreTarget.VIDEO.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_DOWNLOADS, MediaStoreTarget.DOWNLOADS.relativeDirectory)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.uploads
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pins what `res/xml/file_paths.xml` is allowed to hand out.
|
||||
*
|
||||
* The provider root used to be `<external-path path=".">`, i.e. the whole of
|
||||
* `Environment.getExternalStorageDirectory()`. It is now the app-specific
|
||||
* `<external-files-path>`, which is the only external location Amethyst ever
|
||||
* shares from (camera/video capture). These tests fail if either half of that
|
||||
* regresses: the capture paths must still resolve, and the external-storage
|
||||
* root must not.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FileProviderPathsTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val authority = "${context.packageName}.provider"
|
||||
|
||||
@Test
|
||||
fun photoCaptureUriResolves() {
|
||||
val uri = getPhotoUri(context)
|
||||
assertEquals("content", uri.scheme)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoCaptureUriResolves() {
|
||||
val uri = getVideoUri(context)
|
||||
assertEquals("content", uri.scheme)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheDirStillResolves() {
|
||||
val file = File(context.cacheDir, "amethyst_share_probe.png")
|
||||
val uri = FileProvider.getUriForFile(context, authority, file)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the cache root, got $uri", uri.path!!.startsWith("/cache/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalStorageRootIsNoLongerShareable() {
|
||||
@Suppress("DEPRECATION")
|
||||
val outside = File(Environment.getExternalStorageDirectory(), "Download/not-ours.pdf")
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(context, authority, outside)
|
||||
fail("FileProvider should not map $outside, but produced $uri")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Correct: no configured root contains it.
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.note
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The feed's animated elements defer building their `Transition` until a value actually changes,
|
||||
* because first composition has nothing to animate and building one per card per scroll is pure
|
||||
* waste (measured: roughly half the composition cost of every reaction-row button).
|
||||
*
|
||||
* The whole point of deferring rather than removing is that the animation must still play. These
|
||||
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
|
||||
* happens right after the transition is lazily created — still shows outgoing and incoming content
|
||||
* simultaneously, which only a running animation does. A regression that turned the deferral into a
|
||||
* plain snap would show exactly one of them and fail here.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DeferredAnimationTest {
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun deferredCrossfadeStillAnimatesTheFirstChange() {
|
||||
val state = mutableStateOf("A")
|
||||
rule.mainClock.autoAdvance = false
|
||||
|
||||
rule.setContent {
|
||||
DeferredCrossfade(
|
||||
targetState = state.value,
|
||||
modifier = Modifier,
|
||||
contentAlignment = Alignment.TopStart,
|
||||
animationSpec = tween(DURATION_MS),
|
||||
label = "test",
|
||||
) { value ->
|
||||
Text(value, modifier = Modifier.testTag("text_$value"))
|
||||
}
|
||||
}
|
||||
|
||||
// Before any change the transition has not been built, and only the current value renders.
|
||||
rule.onNodeWithTag("text_A").assertIsDisplayed()
|
||||
rule.onNodeWithTag("text_B").assertDoesNotExist()
|
||||
|
||||
state.value = "B"
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
|
||||
|
||||
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
|
||||
rule.onNodeWithTag("text_A").assertExists()
|
||||
rule.onNodeWithTag("text_B").assertExists()
|
||||
|
||||
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
|
||||
rule.onNodeWithTag("text_B").assertIsDisplayed()
|
||||
rule.onNodeWithTag("text_A").assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DURATION_MS = 300
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,10 @@ class AccountZapActions(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
|
||||
@@ -1278,6 +1278,22 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
is GitPullRequestUpdateEvent -> {
|
||||
// Link the update to its parent PR so it lands in the PR's
|
||||
// replies collection (and picks up its target for threading).
|
||||
// The repository ATag isn't a reply target — skip it.
|
||||
listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) })
|
||||
}
|
||||
|
||||
is GitStatusEvent -> {
|
||||
// A status event roots itself at a patch/PR/issue via a
|
||||
// marked-`root` `e` tag; link only that so the transition
|
||||
// appears in the target's replies (GitStatusIndex reduces the
|
||||
// observed stream separately and doesn't need this wiring, but
|
||||
// ThreadFeedView and the notifications-tab reply chain do).
|
||||
listOfNotNull(event.rootEventId()?.let { checkGetOrCreateNote(it) })
|
||||
}
|
||||
|
||||
is TextNoteEvent -> {
|
||||
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
+74
-23
@@ -37,11 +37,14 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -140,28 +143,55 @@ class NwcSignerState(
|
||||
* The negotiated encryption preference for a wallet. NIP-47 says a client
|
||||
* "should always prefer nip44 if supported by the wallet service", so a false
|
||||
* here has to mean "the wallet does not offer NIP-44" — not "we have not asked
|
||||
* yet".
|
||||
*
|
||||
* That distinction is why this waits. The info cache is per-account and held in
|
||||
* memory only, so it starts empty on every app launch, and reading it without
|
||||
* waiting made the first transaction to each wallet after every launch fall
|
||||
* back to NIP-04 even against a wallet advertising `nip44_v2`. Only a cold
|
||||
* cache waits: a stale entry still says what the wallet advertises and is used
|
||||
* as-is while it refreshes in the background.
|
||||
*
|
||||
* The wait is capped, because this sits in front of a payment the user has
|
||||
* already tapped. Its own fetch is bounded only by the relay accessory's 30s
|
||||
* idle window, and the no-response timer below does not start until this
|
||||
* returns. On expiry we send NIP-04 for this one request rather than hold the
|
||||
* tap; the fetch keeps running in the cache's scope, so the next request gets
|
||||
* the negotiated scheme. Never bound the fetch itself instead — a null from it
|
||||
* is cached as a definitive "no info event" for the whole TTL, which would pin
|
||||
* the wallet to NIP-04 for days.
|
||||
* yet". [walletInfo] is what makes that distinction true.
|
||||
*/
|
||||
private suspend fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
|
||||
uri ?: return false
|
||||
val info = withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
|
||||
return info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
|
||||
private fun prefersNip44(info: NwcInfoEvent?): Boolean = info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
|
||||
|
||||
/**
|
||||
* The wallet's advertised capabilities: the one place a send waits on them, and
|
||||
* it waits AT MOST ONCE.
|
||||
*
|
||||
* WAITING IS THE POINT. The info cache is per-account and in memory only, so it
|
||||
* starts empty on every app launch, and reading it without waiting makes "not
|
||||
* fetched yet" indistinguishable from "not supported". That shipped twice: the
|
||||
* first transaction to each wallet after a launch fell back to NIP-04 against a
|
||||
* wallet advertising `nip44_v2`, and a payment to a wallet that had been
|
||||
* advertising NWC-06 for twenty minutes still went out bare — with nothing, on
|
||||
* either side, reporting an error.
|
||||
*
|
||||
* ONCE, because both questions read the same event. Each used to fetch for
|
||||
* itself, which is free on a warm cache and doubles the stall on a cold one:
|
||||
* [NwcInfoCache] deliberately does not cache a FAILED fetch, so with the relay
|
||||
* down both waits ran in full and a 3s worst case became 6s.
|
||||
*
|
||||
* BOUNDED, because this sits in front of a payment the user has already tapped
|
||||
* and the no-response timer does not start until it returns. On expiry the
|
||||
* answer is null — read as NIP-04 and as no-metadata, both of them the safe
|
||||
* direction — while the fetch keeps running in the cache's own scope so the next
|
||||
* request gets the negotiated scheme. Never bound the fetch itself instead: a
|
||||
* null from it is cached as a definitive "no info event" for the whole TTL,
|
||||
* which would pin the wallet to NIP-04 for days.
|
||||
*/
|
||||
private suspend fun walletInfo(uri: Nip47WalletConnect.Nip47URINorm?): NwcInfoEvent? {
|
||||
uri ?: return null
|
||||
return withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips NWC-06 `metadata` from a request bound for a wallet that never said it
|
||||
* understands the field — [MetadataCarrying] has the reason that matters.
|
||||
*
|
||||
* APPLIED WHERE THE REQUEST IS BUILT rather than at each call site, so populating
|
||||
* `metadata` anywhere upstream is safe by construction.
|
||||
*
|
||||
* MUTATES the request in place — see the callers' KDoc. Requests are built per
|
||||
* send and not reused, and stripping a copy would mean rebuilding a params object
|
||||
* whose field list would then drift from the original.
|
||||
*/
|
||||
private fun Request.dropMetadataIfUnsupported(info: NwcInfoEvent?) {
|
||||
val carrier = metadataCarrier ?: return
|
||||
if (carrier.metadata == null || info?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true) return
|
||||
carrier.metadata = null
|
||||
}
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
|
||||
@@ -225,6 +255,10 @@ class NwcSignerState(
|
||||
|
||||
/**
|
||||
* Sends a generic NIP-47 request to a specific wallet.
|
||||
*
|
||||
* [request] MAY BE MUTATED: NWC-06 `metadata` is stripped in place when the
|
||||
* wallet has not advertised support for it. Build a fresh request per send
|
||||
* rather than retaining or re-reading this one.
|
||||
*/
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm?,
|
||||
@@ -235,7 +269,10 @@ class NwcSignerState(
|
||||
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
val walletSigner = buildSigner(walletService) ?: signer
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
|
||||
val info = walletInfo(walletService)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
@@ -266,16 +303,30 @@ class NwcSignerState(
|
||||
|
||||
/**
|
||||
* Sends a zap payment request to the default wallet.
|
||||
*
|
||||
* [metadata] is NWC-06's per-payment blob and is dropped unless the wallet
|
||||
* advertises `06`; see [dropMetadataIfUnsupported].
|
||||
*/
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
|
||||
val info = walletInfo(walletService)
|
||||
val request = PayInvoiceMethod.create(bolt11, metadata)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event =
|
||||
LnZapPaymentRequestEvent.createRequest(
|
||||
request,
|
||||
walletService.pubKeyHex,
|
||||
nip47Signer.value,
|
||||
useNip44 = prefersNip44(info),
|
||||
)
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
@@ -61,6 +62,11 @@ class ZapPaymentHandler(
|
||||
val info: MyZapSplitSetup,
|
||||
val amountMilliSats: Long,
|
||||
val invoice: String,
|
||||
// The signed kind 9734 this invoice was fetched with, and the message on it.
|
||||
// Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for
|
||||
// a NONZAP split, which has no zap request to send.
|
||||
val zapRequest: LnZapRequestEvent? = null,
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
data class UnverifiedZapSplitSetup(
|
||||
@@ -418,6 +424,13 @@ class ZapPaymentHandler(
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = note,
|
||||
// Dropped unless the wallet advertises NWC-06 — see NwcSignerState.
|
||||
metadata =
|
||||
NwcTransactionMetadata.build(
|
||||
zapRequest = payable.zapRequest,
|
||||
recipientIdentifier = payable.info.lnAddress,
|
||||
comment = payable.message,
|
||||
),
|
||||
onResponse = { response ->
|
||||
progress.step()
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
@@ -554,6 +567,10 @@ class ZapPaymentHandler(
|
||||
): Payable {
|
||||
var progressThisPayment = 0.00f
|
||||
|
||||
// Only the request the provider actually accepted may be claimed as bound to
|
||||
// this invoice; see lnAddressInvoice's onZapRequestSent.
|
||||
var sentZapRequest: LnZapRequestEvent? = null
|
||||
|
||||
val invoice =
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnAddress = lud16,
|
||||
@@ -567,6 +584,7 @@ class ZapPaymentHandler(
|
||||
onProgressStep(step)
|
||||
},
|
||||
context = context,
|
||||
onZapRequestSent = { sentZapRequest = it },
|
||||
)
|
||||
|
||||
onProgressStep(1 - progressThisPayment)
|
||||
@@ -575,6 +593,8 @@ class ZapPaymentHandler(
|
||||
info = splitSetup,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = invoice,
|
||||
zapRequest = sentZapRequest,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -201,6 +201,13 @@ class LightningAddressResolver {
|
||||
?: response.code.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onZapRequestSent receives the zap request that was ACTUALLY sent to the
|
||||
* callback, or null when it was not. A provider that does not advertise
|
||||
* `allowsNostr` never sees [nostrRequest], and its invoice therefore commits to
|
||||
* nothing about it — so a caller must not go on to claim the two are bound. See
|
||||
* the drop below.
|
||||
*/
|
||||
suspend fun lnAddressInvoice(
|
||||
lnAddress: String,
|
||||
milliSats: Long,
|
||||
@@ -209,6 +216,7 @@ class LightningAddressResolver {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
|
||||
): String {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
@@ -264,12 +272,19 @@ class LightningAddressResolver {
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-57 binds a zap request to its invoice through `description_hash`, and a
|
||||
// provider that ignores `nostr=` mints an invoice that commits to nothing about
|
||||
// it. Report what actually went, so a caller cannot attach the event to a
|
||||
// payment it was never bound to.
|
||||
val sentZapRequest = nostrRequest?.takeIf { allowsNostr }
|
||||
onZapRequestSent(sentZapRequest)
|
||||
|
||||
val invoice =
|
||||
fetchLightningInvoice(
|
||||
lnCallback = callbackUrl,
|
||||
milliSats = milliSats,
|
||||
message = message,
|
||||
nostrRequest = if (allowsNostr) nostrRequest else null,
|
||||
nostrRequest = sentZapRequest,
|
||||
okHttpClient = okHttpClient,
|
||||
context = context,
|
||||
)
|
||||
|
||||
+10
@@ -76,6 +76,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -278,6 +283,11 @@ class EventNotificationConsumer(
|
||||
is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitReplyEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusOpenEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusAppliedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
|
||||
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
|
||||
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
|
||||
|
||||
+16
-1
@@ -47,6 +47,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -105,7 +110,9 @@ class NotificationDispatcher(
|
||||
// consumeFromCache can't route it. It's delivered directly via
|
||||
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
|
||||
// recipient account.
|
||||
private val NOTIFICATION_KINDS: Set<Int> =
|
||||
// `internal` (was `private`) so the notification-kinds contract test
|
||||
// can pin the push-side kind set against the in-app feed's kind set.
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
PrivateDmEvent.KIND,
|
||||
@@ -131,6 +138,14 @@ class NotificationDispatcher(
|
||||
GitIssueEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 threaded activity: legacy git-reply comment (1622)
|
||||
// and the four status transitions (open/applied/closed/draft,
|
||||
// kinds 1630-1633). Same push channel as issues/patches/PRs.
|
||||
GitReplyEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
|
||||
+99
-2
@@ -35,12 +35,26 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
/**
|
||||
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
|
||||
* (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card
|
||||
* titled by the action ("X opened an issue" …) with the subject as the body.
|
||||
* (1618), PR updates (1619), replies (1622, legacy), and status transitions
|
||||
* (1630 open, 1631 applied/merged, 1632 closed, 1633 draft) on repos or threads
|
||||
* you're p-tagged into. Rendered as a slate card titled by the action ("X opened
|
||||
* an issue", "X merged a pull request", …) with the subject as the body.
|
||||
* Author name + avatar enriched observably.
|
||||
*
|
||||
* Status kinds resolve their title from the *target* event's kind (patch/PR/issue)
|
||||
* when it's in cache, so a merge on a PR reads "merged a pull request" but the
|
||||
* same 1631 targeting a plain kind-1617 patch reads "applied a patch". Falls
|
||||
* back to a generic wording when the target isn't yet resolved (rare: the
|
||||
* notification lands after the target because the p-tag subscription pulls
|
||||
* status events regardless of whether the target has been seen).
|
||||
*/
|
||||
object CodeNotification {
|
||||
suspend fun notify(
|
||||
@@ -67,6 +81,89 @@ object CodeNotification {
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitReplyEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusOpenEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusAppliedEvent,
|
||||
) = post(
|
||||
context,
|
||||
account,
|
||||
event.id,
|
||||
event.createdAt,
|
||||
event.pubKey,
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_applied_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_applied_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_applied_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_applied,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusClosedEvent,
|
||||
) = post(
|
||||
context,
|
||||
account,
|
||||
event.id,
|
||||
event.createdAt,
|
||||
event.pubKey,
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_closed_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_closed_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_closed_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_closed,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusDraftEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
|
||||
|
||||
/**
|
||||
* Pick a title string for a status event based on the *target*'s kind, so
|
||||
* a 1631 on a kind-1618 PR reads "merged a pull request" while the same
|
||||
* status kind on a kind-1617 patch reads "applied a patch". [rootId] is
|
||||
* the marked-`root` `e` tag on the status event; when the target isn't in
|
||||
* cache we return [fallback] which is deliberately generic.
|
||||
*/
|
||||
private fun titleForStatusOnTarget(
|
||||
rootId: String?,
|
||||
pr: Int,
|
||||
patch: Int,
|
||||
issue: Int,
|
||||
fallback: Int,
|
||||
): Int {
|
||||
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
|
||||
return when (targetKind) {
|
||||
GitPullRequestEvent.KIND -> pr
|
||||
GitPatchEvent.KIND -> patch
|
||||
GitIssueEvent.KIND -> issue
|
||||
else -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun post(
|
||||
context: Context,
|
||||
account: Account,
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ object NwcPaymentNotifier {
|
||||
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
|
||||
|
||||
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
|
||||
val comment = (tx.parsedMetadata()?.comment ?: tx.description)?.ifBlank { null }
|
||||
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
|
||||
val body = comment ?: title
|
||||
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
|
||||
+12
@@ -44,6 +44,10 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -111,6 +115,14 @@ val NotificationsPerKeyKinds2 =
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 status events (1630/1631/1632/1633): opened, applied/merged,
|
||||
// closed, drafted. The status author p-tags every prior participant of
|
||||
// the target patch/PR/issue, so a `#p`=me subscription surfaces
|
||||
// "someone merged/closed a thread I'm on" without a repo-scoped query.
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
|
||||
@@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import androidx.collection.mutableScatterMapOf
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -54,7 +56,53 @@ fun <T> CrossfadeIfEnabled(
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Latches the first time a crossfade's target moves off the value it was composed with. */
|
||||
private class ChangeLatch {
|
||||
var changed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
|
||||
* is something to animate.
|
||||
*
|
||||
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
|
||||
* composition*, even though first composition has nothing to cross-fade — target and initial state
|
||||
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
|
||||
* animated element, and during a scroll essentially none of them run, because the underlying counts
|
||||
* and icons do not change in the second a card is on screen.
|
||||
*
|
||||
* So the plain content renders until the target actually moves. At that point the transition is
|
||||
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
|
||||
* the new one, so the first real change still animates exactly as before; every later change
|
||||
* animates through the now-live transition normally.
|
||||
*/
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun <T> DeferredCrossfade(
|
||||
targetState: T,
|
||||
modifier: Modifier,
|
||||
contentAlignment: Alignment,
|
||||
animationSpec: FiniteAnimationSpec<Float>,
|
||||
label: String,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val initial = remember { targetState }
|
||||
val latch = remember { ChangeLatch() }
|
||||
if (targetState != initial) latch.changed = true
|
||||
|
||||
if (!latch.changed) {
|
||||
Box(modifier, contentAlignment) {
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
val transitionState = remember { MutableTransitionState(initial) }
|
||||
transitionState.targetState = targetState
|
||||
val transition = rememberTransition(transitionState, label)
|
||||
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
@@ -57,10 +58,11 @@ object MediaSaverToDisk {
|
||||
resolveBlossom: suspend (String) -> String? = { null },
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) {
|
||||
// No dispatch here: save() and downloadAndSave() both move themselves to IO.
|
||||
when {
|
||||
videoUri.isNullOrBlank() -> {
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
|
||||
videoUri.startsWith("file") -> {
|
||||
@@ -131,18 +133,25 @@ object MediaSaverToDisk {
|
||||
}
|
||||
|
||||
val trimmedUrl = trimInlineMetaData(downloadUrl)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val headerType =
|
||||
response
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.trim()
|
||||
val headerType =
|
||||
response
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.trim()
|
||||
|
||||
val realType =
|
||||
headerType?.takeIf(::isSaveableMimeType)
|
||||
?: mimeType?.takeIf(::isSaveableMimeType)
|
||||
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
|
||||
?: ""
|
||||
// Resolved for both paths: the API level decides how the file is
|
||||
// written, never which directory it belongs in.
|
||||
val realType =
|
||||
headerType?.takeIf(::isSaveableMimeType)
|
||||
?: mimeType?.takeIf(::isSaveableMimeType)
|
||||
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
|
||||
?: ""
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Deliberately Q-only: MediaStore refuses an insert without a usable
|
||||
// type, so there is nothing to do but report it. The legacy path has
|
||||
// always written whatever it downloaded and still does - an unresolved
|
||||
// type lands in Downloads, which accepts any file.
|
||||
check(realType.isNotBlank()) { "Can't find out the content type" }
|
||||
|
||||
saveContentQ(
|
||||
@@ -154,6 +163,7 @@ object MediaSaverToDisk {
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = File(trimmedUrl).name,
|
||||
contentType = realType,
|
||||
contentSource = response.body.source(),
|
||||
context = context,
|
||||
)
|
||||
@@ -176,44 +186,53 @@ object MediaSaverToDisk {
|
||||
private fun isSaveableMimeType(type: String): Boolean =
|
||||
type.isNotBlank() &&
|
||||
(
|
||||
type.startsWith("image/", ignoreCase = true) ||
|
||||
type.startsWith("video/", ignoreCase = true) ||
|
||||
type.startsWith("audio/", ignoreCase = true) ||
|
||||
MediaStoreTarget.of(type) != MediaStoreTarget.DOWNLOADS ||
|
||||
type.equals(PDF_MIME_TYPE, ignoreCase = true)
|
||||
)
|
||||
|
||||
/**
|
||||
* Copies a local file into the gallery. Suspending and dispatched to IO like
|
||||
* [downloadAndSave]: callers reach this from click handlers, and a
|
||||
* storage-permission callback among them launches on the main dispatcher,
|
||||
* where copying a whole video would block the UI thread.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun save(
|
||||
suspend fun save(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
try {
|
||||
val extension =
|
||||
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = Uuid.random().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = "${Uuid.random()}.$extension",
|
||||
contentSource = buffer,
|
||||
context = context,
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// use{}: readAll leaves its source open, so without this the file
|
||||
// descriptor stays open until the finalizer runs.
|
||||
localFile.inputStream().source().buffer().use { buffer ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = Uuid.random().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
val extension =
|
||||
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
saveContentDefault(
|
||||
fileName = "${Uuid.random()}.$extension",
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MediaSaverToDisk", "Unable to save", e)
|
||||
onError(e)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MediaSaverToDisk", "Unable to save", e)
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,29 +244,7 @@ object MediaSaverToDisk {
|
||||
contentResolver: ContentResolver,
|
||||
) {
|
||||
val cleanMimeType = normalizeMimeTypeForMediaStore(contentType.substringBefore(";").trim())
|
||||
|
||||
val (masterUri, baseDir) =
|
||||
when {
|
||||
cleanMimeType.startsWith("image/", ignoreCase = true) -> {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
|
||||
cleanMimeType.startsWith("audio/", ignoreCase = true) -> {
|
||||
// Audio content goes into the Music MediaStore + folder. Routing it through
|
||||
// Video.EXTERNAL_CONTENT_URI (the previous fall-through behavior) crashes
|
||||
// with IllegalArgumentException because MediaProvider rejects audio/* into
|
||||
// the Video collection.
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MUSIC
|
||||
}
|
||||
|
||||
cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> {
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS
|
||||
}
|
||||
|
||||
else -> {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
}
|
||||
val target = MediaStoreTarget.of(cleanMimeType)
|
||||
|
||||
val contentValues =
|
||||
ContentValues().apply {
|
||||
@@ -255,11 +252,11 @@ object MediaSaverToDisk {
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType)
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
baseDir + File.separatorChar + AMETHYST_SUBDIRECTORY,
|
||||
target.relativeDirectory + File.separatorChar + AMETHYST_SUBDIRECTORY,
|
||||
)
|
||||
}
|
||||
|
||||
val uri = contentResolver.insert(masterUri, contentValues)
|
||||
val uri = contentResolver.insert(target.collectionUri(), contentValues)
|
||||
checkNotNull(uri) { "Can't insert the new content" }
|
||||
|
||||
try {
|
||||
@@ -276,12 +273,15 @@ object MediaSaverToDisk {
|
||||
|
||||
private fun saveContentDefault(
|
||||
fileName: String,
|
||||
contentType: String,
|
||||
contentSource: BufferedSource,
|
||||
context: Context,
|
||||
) {
|
||||
val baseDir = MediaStoreTarget.of(contentType).relativeDirectory
|
||||
|
||||
val subdirectory =
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
Environment.getExternalStoragePublicDirectory(baseDir),
|
||||
AMETHYST_SUBDIRECTORY,
|
||||
).apply {
|
||||
if (!exists()) mkdirs()
|
||||
@@ -307,6 +307,60 @@ object MediaSaverToDisk {
|
||||
else -> mimeType
|
||||
}
|
||||
|
||||
/**
|
||||
* The MediaStore collection a download is filed under, together with the public
|
||||
* directory it is written to.
|
||||
*
|
||||
* MediaProvider validates the primary directory of [MediaStore.MediaColumns.RELATIVE_PATH]
|
||||
* against the collection being inserted into and rejects a mismatch with
|
||||
* `IllegalArgumentException: Primary directory Pictures not allowed for
|
||||
* content://media/external/video/media; allowed directories are [DCIM, Movies]`.
|
||||
* A collection usually accepts more than one directory; these are the ones Amethyst
|
||||
* files under.
|
||||
*/
|
||||
internal enum class MediaStoreTarget(
|
||||
val relativeDirectory: String,
|
||||
) {
|
||||
// The directory names are the values of Environment.DIRECTORY_PICTURES, _MUSIC,
|
||||
// _MOVIES and _DOWNLOADS. They are spelled out because those are plain static
|
||||
// fields that the unit-test android.jar leaves null, which would make this
|
||||
// mapping impossible to cover off-device. MediaStoreTargetInstrumentedTest pins
|
||||
// them back to the platform constants on-device.
|
||||
IMAGES("Pictures"),
|
||||
AUDIO("Music"),
|
||||
VIDEO("Movies"),
|
||||
DOWNLOADS("Download"),
|
||||
;
|
||||
|
||||
/**
|
||||
* Has to stay a method. The EXTERNAL_CONTENT_URI fields are null under the same
|
||||
* unit-test android.jar, and MediaStore.Downloads only exists from API 29, so
|
||||
* reading them from the constructor would break class init off-device and below Q.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun collectionUri(): Uri =
|
||||
when (this) {
|
||||
IMAGES -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
AUDIO -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
DOWNLOADS -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* PDFs, and anything that isn't image, audio or video content, go to Downloads
|
||||
* — the one collection that accepts every kind of file.
|
||||
*/
|
||||
fun of(mimeType: String): MediaStoreTarget =
|
||||
when {
|
||||
mimeType.startsWith("image/", ignoreCase = true) -> IMAGES
|
||||
mimeType.startsWith("audio/", ignoreCase = true) -> AUDIO
|
||||
mimeType.startsWith("video/", ignoreCase = true) -> VIDEO
|
||||
else -> DOWNLOADS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
|
||||
private const val PDF_MIME_TYPE = "application/pdf"
|
||||
private const val BLOSSOM_SCHEME = "blossom:"
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -857,11 +858,7 @@ private fun SlidingAnimationCount(
|
||||
if (accountViewModel.settings.isPerformanceMode()) {
|
||||
TextCount(baseCount, textColor)
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = baseCount,
|
||||
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec,
|
||||
label = "SlidingAnimationCount",
|
||||
) { count ->
|
||||
DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count ->
|
||||
TextCount(count, textColor)
|
||||
}
|
||||
}
|
||||
@@ -884,6 +881,48 @@ val slideAnimation: ContentTransform =
|
||||
),
|
||||
)
|
||||
|
||||
/** Latches the first time an animated counter's value moves off the one it was composed with. */
|
||||
private class CountChangeLatch {
|
||||
var changed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* An [AnimatedContent] that does not build its transition until the value actually changes.
|
||||
*
|
||||
* Same reasoning as `DeferredCrossfade`: `AnimatedContent` builds a transition plus its content map
|
||||
* and size animation on first composition, but first composition has nothing to animate. A reaction
|
||||
* counter only slides when the count moves, which practically never happens in the second a card
|
||||
* spends on screen during a scroll — so the apparatus was built and thrown away, once per counter
|
||||
* per card.
|
||||
*
|
||||
* Rendering the bare content until the first change, then seeding a [MutableTransitionState] at the
|
||||
* original value, keeps that first change animated exactly as before.
|
||||
*/
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
private fun <T> DeferredAnimatedContent(
|
||||
targetState: T,
|
||||
label: String,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val initial = remember { targetState }
|
||||
val latch = remember { CountChangeLatch() }
|
||||
if (targetState != initial) latch.changed = true
|
||||
|
||||
if (!latch.changed) {
|
||||
content(targetState)
|
||||
} else {
|
||||
val transitionState = remember { MutableTransitionState(initial) }
|
||||
transitionState.targetState = targetState
|
||||
val transition = rememberTransition(transitionState, label)
|
||||
transition.AnimatedContent(
|
||||
transitionSpec = { transitionSpec() },
|
||||
) { value ->
|
||||
content(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextCount(
|
||||
count: Int,
|
||||
@@ -911,11 +950,7 @@ fun SlidingAnimationAmount(
|
||||
maxLines = 1,
|
||||
)
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = amount,
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
|
||||
label = "SlidingAnimationAmount",
|
||||
) { count ->
|
||||
DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = Font14SP,
|
||||
|
||||
+2
-1
@@ -2839,9 +2839,10 @@ class AccountViewModel(
|
||||
zappedNote: Note?,
|
||||
onSent: () -> Unit = {},
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) = launchSigner {
|
||||
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, onResponse)
|
||||
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
|
||||
onSent()
|
||||
}
|
||||
|
||||
|
||||
+44
-36
@@ -20,25 +20,33 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import org.json.JSONObject
|
||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.doubleOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.intOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.objectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */
|
||||
fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? {
|
||||
fun parseSelectionGeometry(o: JsonObject?): SelectionGeometry? {
|
||||
if (o == null) return null
|
||||
return SelectionGeometry(
|
||||
left = o.optDouble("l", 0.0).toFloat(),
|
||||
top = o.optDouble("t", 0.0).toFloat(),
|
||||
right = o.optDouble("r", 0.0).toFloat(),
|
||||
bottom = o.optDouble("b", 0.0).toFloat(),
|
||||
startX = o.optDouble("sx", 0.0).toFloat(),
|
||||
startBottom = o.optDouble("sb", 0.0).toFloat(),
|
||||
endX = o.optDouble("ex", 0.0).toFloat(),
|
||||
endBottom = o.optDouble("eb", 0.0).toFloat(),
|
||||
viewportWidth = o.optDouble("vw", 0.0).toFloat(),
|
||||
caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null,
|
||||
caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null,
|
||||
caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null,
|
||||
isRange = o.optBoolean("rng", false),
|
||||
left = (o.doubleOrNull("l") ?: 0.0).toFloat(),
|
||||
top = (o.doubleOrNull("t") ?: 0.0).toFloat(),
|
||||
right = (o.doubleOrNull("r") ?: 0.0).toFloat(),
|
||||
bottom = (o.doubleOrNull("b") ?: 0.0).toFloat(),
|
||||
startX = (o.doubleOrNull("sx") ?: 0.0).toFloat(),
|
||||
startBottom = (o.doubleOrNull("sb") ?: 0.0).toFloat(),
|
||||
endX = (o.doubleOrNull("ex") ?: 0.0).toFloat(),
|
||||
endBottom = (o.doubleOrNull("eb") ?: 0.0).toFloat(),
|
||||
viewportWidth = (o.doubleOrNull("vw") ?: 0.0).toFloat(),
|
||||
caretX = o.doubleOrNull("cx")?.toFloat(),
|
||||
caretTop = o.doubleOrNull("ct")?.toFloat(),
|
||||
caretBottom = o.doubleOrNull("cb")?.toFloat(),
|
||||
isRange = o.booleanOrNull("rng") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,45 +70,45 @@ interface EmbeddedImeBridge {
|
||||
* learns there is a field to put the keyboard back on. Sent when a tab becomes the active one again, and when
|
||||
* an [ImeEvent.WantKeyboard] tap arrives for a field this host no longer mirrors.
|
||||
*/
|
||||
fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString())
|
||||
fun EmbeddedImeBridge.requestImeResync() = sendImeOp(buildJsonObject { put("type", "ime.resync") }.toString())
|
||||
|
||||
/** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */
|
||||
fun parseImeEvent(payload: String): ImeEvent? {
|
||||
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
|
||||
return when (o.optString("type")) {
|
||||
val o = parseJsonObjectOrNull(payload) ?: return null
|
||||
return when (o.stringOrNull("type")) {
|
||||
"ime.focus" -> parseFocus(o)
|
||||
"ime.wantkb" -> ImeEvent.WantKeyboard
|
||||
"ime.refocus" -> ImeEvent.ReFocus(parseFocus(o))
|
||||
"ime.blur" -> ImeEvent.Blur
|
||||
"ime.state" ->
|
||||
ImeEvent.State(
|
||||
text = o.optString("text", ""),
|
||||
selStart = o.optInt("selStart", 0),
|
||||
selEnd = o.optInt("selEnd", 0),
|
||||
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
|
||||
text = o.stringOrNull("text") ?: "",
|
||||
selStart = o.intOrNull("selStart") ?: 0,
|
||||
selEnd = o.intOrNull("selEnd") ?: 0,
|
||||
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
|
||||
)
|
||||
"ime.pagesel" ->
|
||||
ImeEvent.PageSelection(
|
||||
active = o.optBoolean("active", false),
|
||||
text = o.optString("text", ""),
|
||||
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
|
||||
active = o.booleanOrNull("active") ?: false,
|
||||
text = o.stringOrNull("text") ?: "",
|
||||
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
|
||||
)
|
||||
"ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false))
|
||||
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom")))
|
||||
"ime.scroll" -> ImeEvent.Scroll(active = o.booleanOrNull("active") ?: false)
|
||||
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.objectOrNull("geom")))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFocus(o: JSONObject) =
|
||||
private fun parseFocus(o: JsonObject) =
|
||||
ImeEvent.Focus(
|
||||
inputType = o.optString("inputType", "text"),
|
||||
enterKeyHint = o.optString("enterKeyHint", ""),
|
||||
multiline = o.optBoolean("multiline", false),
|
||||
readOnly = o.optBoolean("readOnly", false),
|
||||
text = o.optString("text", ""),
|
||||
selStart = o.optInt("selStart", 0),
|
||||
selEnd = o.optInt("selEnd", 0),
|
||||
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
|
||||
inputType = o.stringOrNull("inputType") ?: "text",
|
||||
enterKeyHint = o.stringOrNull("enterKeyHint") ?: "",
|
||||
multiline = o.booleanOrNull("multiline") ?: false,
|
||||
readOnly = o.booleanOrNull("readOnly") ?: false,
|
||||
text = o.stringOrNull("text") ?: "",
|
||||
selStart = o.intOrNull("selStart") ?: 0,
|
||||
selEnd = o.intOrNull("selEnd") ?: 0,
|
||||
geometry = parseSelectionGeometry(o.objectOrNull("geom")),
|
||||
)
|
||||
|
||||
/** What the focused page field reports up to the host keyboard. */
|
||||
|
||||
+16
-8
@@ -83,7 +83,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import kotlinx.coroutines.delay
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width.
|
||||
@@ -106,12 +107,12 @@ private fun EmbeddedImeBridge.sendFieldOp(
|
||||
cssX: Float,
|
||||
cssY: Float,
|
||||
) = sendImeOp(
|
||||
JSONObject()
|
||||
.put("type", type)
|
||||
.apply { if (edge != null) put("edge", edge) }
|
||||
.put("x", cssX.toDouble())
|
||||
.put("y", cssY.toDouble())
|
||||
.toString(),
|
||||
buildJsonObject {
|
||||
put("type", type)
|
||||
if (edge != null) put("edge", edge)
|
||||
put("x", cssX.toDouble())
|
||||
put("y", cssY.toDouble())
|
||||
}.toString(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -508,7 +509,14 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS
|
||||
else -> 0.0
|
||||
}
|
||||
if (dy != 0.0) imeBridge?.sendImeOp(JSONObject().put("type", "ime.autoscroll").put("dy", dy).toString())
|
||||
if (dy != 0.0) {
|
||||
imeBridge?.sendImeOp(
|
||||
buildJsonObject {
|
||||
put("type", "ime.autoscroll")
|
||||
put("dy", dy)
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!active || magProbe == null) {
|
||||
magnifier.hide()
|
||||
|
||||
+13
-10
@@ -32,7 +32,9 @@ import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputConnectionWrapper
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* The main-app-window home for the soft keyboard when a field is focused inside an embedded WebView. The
|
||||
@@ -124,7 +126,7 @@ class RemoteImeView(
|
||||
)
|
||||
// The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline).
|
||||
setOnEditorActionListener { _, _, _ ->
|
||||
bridge?.sendImeOp(JSONObject().put("type", "ime.action").toString())
|
||||
bridge?.sendImeOp(buildJsonObject { put("type", "ime.action") }.toString())
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -365,12 +367,12 @@ class RemoteImeView(
|
||||
schedule()
|
||||
}
|
||||
|
||||
private fun stateJson(): JSONObject {
|
||||
private fun stateJson(): JsonObject {
|
||||
val editable = text
|
||||
val composingStart = if (editable != null) BaseInputConnection.getComposingSpanStart(editable) else -1
|
||||
val composingEnd = if (editable != null) BaseInputConnection.getComposingSpanEnd(editable) else -1
|
||||
return JSONObject()
|
||||
.put("type", "ime.set")
|
||||
return buildJsonObject {
|
||||
put("type", "ime.set")
|
||||
// A readonly field's text must never travel back to the page. TYPE_NULL keeps the *user* from
|
||||
// typing into the mirror, but the mirror still flushes on selection changes — a long-press
|
||||
// select-all, then Chrome's collapse-to-endpoint, both emit one — and that flush is delivered
|
||||
@@ -381,11 +383,12 @@ class RemoteImeView(
|
||||
// Omitting the key (rather than sending the current text) makes the shim treat the message as
|
||||
// selection-only — `var next = (msg.text != null) ? String(msg.text) : prev` — so the
|
||||
// host-drawn handles and Copy keep working off a synced selection while nothing can be written.
|
||||
.apply { if (!fieldReadOnly) put("text", editable?.toString() ?: "") }
|
||||
.put("selStart", selectionStart)
|
||||
.put("selEnd", selectionEnd)
|
||||
.put("composingStart", composingStart)
|
||||
.put("composingEnd", composingEnd)
|
||||
if (!fieldReadOnly) put("text", editable?.toString() ?: "")
|
||||
put("selStart", selectionStart)
|
||||
put("selEnd", selectionEnd)
|
||||
put("composingStart", composingStart)
|
||||
put("composingEnd", composingEnd)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushState() {
|
||||
|
||||
+26
-1
@@ -67,6 +67,12 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
@@ -173,6 +179,18 @@ class NotificationFeedFilter(
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 threaded activity: legacy comment (1622, deprecated by
|
||||
// NIP-22 but still in the wild) and the four status transitions
|
||||
// (open/applied/closed/draft, kinds 1630-1633). Included so
|
||||
// "someone merged/closed my PR" and "someone commented on my
|
||||
// patch" surface on the Notifications tab — without this the
|
||||
// status kinds arrive via the p-tag subscription and sit in
|
||||
// LocalCache invisible.
|
||||
GitReplyEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
@@ -262,8 +280,15 @@ class NotificationFeedFilter(
|
||||
}
|
||||
|
||||
if (event is GitIssueEvent || event is GitPatchEvent ||
|
||||
event is GitPullRequestEvent || event is GitPullRequestUpdateEvent
|
||||
event is GitPullRequestEvent || event is GitPullRequestUpdateEvent ||
|
||||
event is GitReplyEvent || event is GitStatusEvent
|
||||
) {
|
||||
// NIP-34 events reach the notifications tab only via the p-tag
|
||||
// relay filter, which already selected them because the current
|
||||
// user is on the participant list. Any further check would
|
||||
// require walking a chain of prior status/reply events that
|
||||
// aren't guaranteed to be in cache; short-circuit to trust the
|
||||
// p-tag — same policy applied to issues/patches/PRs above.
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -72,8 +72,14 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -248,8 +254,14 @@ fun kindDisplayName(kind: Int): Int =
|
||||
EphemeralGiftWrapEvent.KIND -> R.string.kind_gift_wraps
|
||||
GitIssueEvent.KIND -> R.string.kind_git_issue
|
||||
GitPatchEvent.KIND -> R.string.kind_git_patch
|
||||
GitPullRequestEvent.KIND -> R.string.kind_git_pr
|
||||
GitPullRequestUpdateEvent.KIND -> R.string.kind_git_pr_update
|
||||
GitRepositoryEvent.KIND -> R.string.kind_git_repo
|
||||
GitReplyEvent.KIND -> R.string.kind_git_reply
|
||||
GitStatusOpenEvent.KIND -> R.string.kind_git_status_open
|
||||
GitStatusAppliedEvent.KIND -> R.string.kind_git_status_applied
|
||||
GitStatusClosedEvent.KIND -> R.string.kind_git_status_closed
|
||||
GitStatusDraftEvent.KIND -> R.string.kind_git_status_draft
|
||||
GoalEvent.KIND -> R.string.kind_zap_goals
|
||||
HashtagListEvent.KIND -> R.string.kind_hashtag_follows
|
||||
HighlightEvent.KIND -> R.string.kind_highlights
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.wallet
|
||||
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
|
||||
|
||||
/**
|
||||
* What a transaction row has to say about its counterparty, resolved away from
|
||||
* Compose so the blank-handling is a plain unit test.
|
||||
*
|
||||
* The direction label ("Received"/"Sent") is passed IN rather than looked up here,
|
||||
* which is what keeps this Context-free while still letting it be the single place
|
||||
* that decides what a row says.
|
||||
*/
|
||||
data class TransactionRowLabels(
|
||||
val title: Title,
|
||||
val subtitle: String?,
|
||||
) {
|
||||
sealed interface Title {
|
||||
/** Render the counterparty's profile for this pubkey, falling back to [name]. */
|
||||
data class User(
|
||||
val pubkeyHex: String,
|
||||
val name: String?,
|
||||
) : Title
|
||||
|
||||
data class Literal(
|
||||
val text: String,
|
||||
) : Title
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* WHY EVERY READ IS BLANK-GUARDED, not just null-checked. Wallets send
|
||||
* `"description": ""` for a payment with no memo — NIP-47 marks the field
|
||||
* optional, but omitting it is a choice and several wallets emit the empty
|
||||
* string instead. An elvis only catches null, so the row rendered an empty
|
||||
* Text: an invisible line with the height of a real one, which is why
|
||||
* outgoing rows looked like a bare arrow and a date.
|
||||
*/
|
||||
fun resolve(
|
||||
tx: NwcTransaction,
|
||||
directionLabel: String,
|
||||
): TransactionRowLabels {
|
||||
val isIncoming = tx.type == NwcTransactionType.INCOMING
|
||||
val parsed = tx.parsedMetadata()
|
||||
val description = tx.displayDescription()
|
||||
|
||||
// Incoming: who sent it. Outgoing: who received it — on a zap request the
|
||||
// `p` tag is the payee, which is what makes an outgoing row resolvable.
|
||||
val pubkeyHex = if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex()
|
||||
val displayName = if (isIncoming) parsed?.senderDisplayName() else parsed?.recipientIdentifier()
|
||||
|
||||
// The zap comment, unless it merely repeats the description.
|
||||
val comment =
|
||||
parsed?.displayComment()?.takeIf { comment ->
|
||||
description == null || !comment.equals(description, ignoreCase = true)
|
||||
}
|
||||
|
||||
// A title that NAMES a counterparty wants a second line saying what the
|
||||
// payment was; a title that IS the description must not repeat it below.
|
||||
val named = pubkeyHex?.let { Title.User(it, displayName) } ?: displayName?.let { Title.Literal(it) }
|
||||
val fallback = description ?: directionLabel
|
||||
|
||||
return TransactionRowLabels(
|
||||
title = named ?: Title.Literal(fallback),
|
||||
subtitle = comment ?: fallback.takeIf { named != null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-55
@@ -271,35 +271,14 @@ private fun TransactionItem(
|
||||
tx.created_at?.let { formatMonthDayTime(it, context) } ?: ""
|
||||
}
|
||||
|
||||
val parsed = remember(tx.metadata) { tx.parsedMetadata() }
|
||||
val directionLabel =
|
||||
if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing)
|
||||
|
||||
// For incoming: show who sent it (nostr pubkey or payer name/email)
|
||||
// For outgoing: show who received it (nostr recipient or recipient identifier)
|
||||
val counterpartyPubkeyHex =
|
||||
remember(parsed) {
|
||||
if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex()
|
||||
}
|
||||
|
||||
val counterpartyDisplayName =
|
||||
remember(parsed) {
|
||||
if (isIncoming) {
|
||||
parsed?.senderDisplayName()
|
||||
} else {
|
||||
parsed?.recipientIdentifier()
|
||||
}
|
||||
}
|
||||
|
||||
// Show comment only if it differs from description
|
||||
val commentText =
|
||||
remember(parsed, tx.description) {
|
||||
parsed?.comment?.let { comment ->
|
||||
if (tx.description == null || !comment.equals(tx.description, ignoreCase = true)) {
|
||||
comment
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val labels =
|
||||
remember(tx.metadata, tx.description, tx.type, directionLabel) {
|
||||
TransactionRowLabels.resolve(tx, directionLabel)
|
||||
}
|
||||
val counterpartyPubkeyHex = (labels.title as? TransactionRowLabels.Title.User)?.pubkeyHex
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
@@ -337,37 +316,23 @@ private fun TransactionItem(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (counterpartyPubkeyHex != null) {
|
||||
TransactionUserName(counterpartyPubkeyHex, counterpartyDisplayName, accountViewModel)
|
||||
} else if (counterpartyDisplayName != null) {
|
||||
Text(
|
||||
text = counterpartyDisplayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
when (val title = labels.title) {
|
||||
is TransactionRowLabels.Title.User ->
|
||||
TransactionUserName(title.pubkeyHex, title.name, accountViewModel)
|
||||
|
||||
is TransactionRowLabels.Title.Literal ->
|
||||
Text(
|
||||
text = title.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (commentText != null) {
|
||||
labels.subtitle?.let {
|
||||
Text(
|
||||
text = commentText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else if (counterpartyPubkeyHex != null || counterpartyDisplayName != null) {
|
||||
val descOrType = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing)
|
||||
Text(
|
||||
text = descOrType,
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
+1
@@ -549,6 +549,7 @@ class WalletViewModel : ViewModel() {
|
||||
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
|
||||
val acc = account ?: return
|
||||
val walletUri = getWalletUri(walletId) ?: return
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
|
||||
@@ -4849,7 +4849,6 @@
|
||||
<string name="ai_tone_elaborate">Rozvést</string>
|
||||
<string name="ai_tone_friendly">Přátelský</string>
|
||||
<string name="ai_tone_professional">Profesionální</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Emoji balíčky</string>
|
||||
<string name="emoji_pack_management_title">Přidat do seznamu emoji</string>
|
||||
|
||||
@@ -4643,7 +4643,6 @@
|
||||
<string name="ai_tone_elaborate">Ausführen</string>
|
||||
<string name="ai_tone_friendly">Freundlich</string>
|
||||
<string name="ai_tone_professional">Professionell</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Emoji-Pakete</string>
|
||||
<string name="emoji_pack_management_title">Zur Emoji-Liste hinzufügen</string>
|
||||
|
||||
@@ -4606,7 +4606,6 @@
|
||||
<string name="ai_tone_elaborate">Uitbreiden</string>
|
||||
<string name="ai_tone_friendly">Vriendelijk</string>
|
||||
<string name="ai_tone_professional">Professioneel</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Emoji-pakketten</string>
|
||||
<string name="emoji_pack_management_title">Toevoegen aan emoji-lijst</string>
|
||||
|
||||
@@ -4641,7 +4641,6 @@
|
||||
<string name="ai_tone_elaborate">Elaborar</string>
|
||||
<string name="ai_tone_friendly">Amigável</string>
|
||||
<string name="ai_tone_professional">Profissional</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Pacotes de emojis</string>
|
||||
<string name="emoji_pack_management_title">Adicionar à lista de emojis</string>
|
||||
|
||||
@@ -4849,7 +4849,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="ai_tone_elaborate">Razloži podrobneje</string>
|
||||
<string name="ai_tone_friendly">Prijateljsko</string>
|
||||
<string name="ai_tone_professional">Profesionalno</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Emoji paketi</string>
|
||||
<string name="emoji_pack_management_title">Dodaj v seznam emojijev</string>
|
||||
|
||||
@@ -4641,7 +4641,6 @@
|
||||
<string name="ai_tone_elaborate">Utveckla</string>
|
||||
<string name="ai_tone_friendly">Vänlig</string>
|
||||
<string name="ai_tone_professional">Professionell</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">Emoji-paket</string>
|
||||
<string name="emoji_pack_management_title">Lägg till i emoji-lista</string>
|
||||
|
||||
@@ -3885,7 +3885,6 @@
|
||||
<string name="ai_tone_elaborate">详述</string>
|
||||
<string name="ai_tone_friendly">友好</string>
|
||||
<string name="ai_tone_professional">专业</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<!-- Emoji packs -->
|
||||
<string name="emoji_packs_title">表情包</string>
|
||||
<string name="emoji_pack_management_title">添加到表情列表</string>
|
||||
|
||||
@@ -2085,11 +2085,22 @@
|
||||
<!-- Code & git -->
|
||||
<string name="app_notification_code_channel_id" translatable="false">CodeID</string>
|
||||
<string name="app_notification_code_channel_name">Code & Git</string>
|
||||
<string name="app_notification_code_channel_description">Notifies you about issues, patches, and pull requests</string>
|
||||
<string name="app_notification_code_channel_description">Notifies you about issues, patches, pull requests, comments, merges, and closes</string>
|
||||
<string name="app_notification_code_channel_message_issue">%1$s opened an issue</string>
|
||||
<string name="app_notification_code_channel_message_patch">%1$s sent a patch</string>
|
||||
<string name="app_notification_code_channel_message_pr">%1$s opened a pull request</string>
|
||||
<string name="app_notification_code_channel_message_pr_update">%1$s updated a pull request</string>
|
||||
<string name="app_notification_code_channel_message_reply">%1$s commented</string>
|
||||
<string name="app_notification_code_channel_message_status_open">%1$s reopened a thread</string>
|
||||
<string name="app_notification_code_channel_message_status_applied_pr">%1$s merged a pull request</string>
|
||||
<string name="app_notification_code_channel_message_status_applied_patch">%1$s applied a patch</string>
|
||||
<string name="app_notification_code_channel_message_status_applied_issue">%1$s resolved an issue</string>
|
||||
<string name="app_notification_code_channel_message_status_applied">%1$s merged/resolved a thread</string>
|
||||
<string name="app_notification_code_channel_message_status_closed_pr">%1$s closed a pull request</string>
|
||||
<string name="app_notification_code_channel_message_status_closed_patch">%1$s closed a patch</string>
|
||||
<string name="app_notification_code_channel_message_status_closed_issue">%1$s closed an issue</string>
|
||||
<string name="app_notification_code_channel_message_status_closed">%1$s closed a thread</string>
|
||||
<string name="app_notification_code_channel_message_status_draft">%1$s marked a thread as draft</string>
|
||||
<string name="app_notification_code_summary">New code activity</string>
|
||||
|
||||
<!-- Badges -->
|
||||
@@ -4518,6 +4529,10 @@
|
||||
<string name="kind_git_reply">Git Reply</string>
|
||||
<string name="kind_git_pr">Pull Request</string>
|
||||
<string name="kind_git_pr_update">PR Update</string>
|
||||
<string name="kind_git_status_open">Git Status: Open</string>
|
||||
<string name="kind_git_status_applied">Git Status: Applied</string>
|
||||
<string name="kind_git_status_closed">Git Status: Closed</string>
|
||||
<string name="kind_git_status_draft">Git Status: Draft</string>
|
||||
<string name="kind_zap_goals">Zap Goals</string>
|
||||
<string name="kind_hashtag_follows">Hashtag Follows</string>
|
||||
<string name="kind_highlights">Highlights</string>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path
|
||||
<!-- Camera/video capture (TakePicture) writes into getExternalFilesDir(...),
|
||||
the app-specific external dir. Declared precisely so the provider never
|
||||
roots at the external-storage top level, and so it stays correct under
|
||||
the .debug / .benchmark applicationIdSuffixes. -->
|
||||
<external-files-path
|
||||
name="external_files"
|
||||
path="." />
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -47,4 +48,34 @@ class MediaSaverToDiskTest {
|
||||
assertEquals("image/png", MediaSaverToDisk.normalizeMimeTypeForMediaStore("image/png"))
|
||||
assertEquals("audio/mpeg", MediaSaverToDisk.normalizeMimeTypeForMediaStore("audio/mpeg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routesEachMediaKindToItsOwnCollection() {
|
||||
assertEquals(MediaStoreTarget.IMAGES, MediaStoreTarget.of("image/jpeg"))
|
||||
assertEquals(MediaStoreTarget.AUDIO, MediaStoreTarget.of("audio/mpeg"))
|
||||
assertEquals(MediaStoreTarget.VIDEO, MediaStoreTarget.of("video/mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routesPdfsAndUnknownTypesToDownloads() {
|
||||
assertEquals(MediaStoreTarget.DOWNLOADS, MediaStoreTarget.of("application/pdf"))
|
||||
assertEquals(MediaStoreTarget.DOWNLOADS, MediaStoreTarget.of("application/zip"))
|
||||
assertEquals(MediaStoreTarget.DOWNLOADS, MediaStoreTarget.of(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routingIsCaseInsensitive() {
|
||||
assertEquals(MediaStoreTarget.IMAGES, MediaStoreTarget.of("Image/PNG"))
|
||||
assertEquals(MediaStoreTarget.AUDIO, MediaStoreTarget.of("Audio/MPEG"))
|
||||
assertEquals(MediaStoreTarget.VIDEO, MediaStoreTarget.of("Video/MP4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachCollectionIsPairedWithADirectoryMediaProviderAcceptsForIt() {
|
||||
// Video content filed under "Pictures" is the rejection reported in #4009.
|
||||
assertEquals("Pictures", MediaStoreTarget.IMAGES.relativeDirectory)
|
||||
assertEquals("Music", MediaStoreTarget.AUDIO.relativeDirectory)
|
||||
assertEquals("Movies", MediaStoreTarget.VIDEO.relativeDirectory)
|
||||
assertEquals("Download", MediaStoreTarget.DOWNLOADS.relativeDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
+217
@@ -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.screen.loggedIn.embed
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the host-side half of the page↔host IME contract: how [parseImeEvent] and
|
||||
* [parseSelectionGeometry] read the shim's `ime.*` envelopes, including the defaulting
|
||||
* behavior for absent/mistyped fields the kotlinx.serialization migration settled on
|
||||
* (total accessors — a field the shim never sent, or sent malformed, degrades to the
|
||||
* documented default instead of throwing). The page-side half (which envelopes real
|
||||
* browser gestures produce) lives in `tools/ime-test/shim-events.mjs`.
|
||||
*/
|
||||
class EmbeddedImeBridgeTest {
|
||||
private fun geom(raw: String) = parseSelectionGeometry(Json.parseToJsonElement(raw).jsonObject)
|
||||
|
||||
// ---- malformed / unrecognized payloads ----
|
||||
|
||||
@Test
|
||||
fun rejectsPayloadsThatAreNotJsonObjects() {
|
||||
assertNull(parseImeEvent("not json"))
|
||||
assertNull(parseImeEvent(""))
|
||||
assertNull(parseImeEvent("[1,2]"))
|
||||
assertNull(parseImeEvent("\"ime.blur\""))
|
||||
assertNull(parseImeEvent("42"))
|
||||
assertNull(parseImeEvent("null"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnknownOrMissingType() {
|
||||
assertNull(parseImeEvent("""{"type":"ime.unknown"}"""))
|
||||
assertNull(parseImeEvent("""{"id":"1"}"""))
|
||||
assertNull(parseImeEvent("""{"type":7}""")) // coerces to "7", which matches nothing
|
||||
}
|
||||
|
||||
// ---- payload-free events ----
|
||||
|
||||
@Test
|
||||
fun parsesPayloadFreeEvents() {
|
||||
assertEquals(ImeEvent.WantKeyboard, parseImeEvent("""{"type":"ime.wantkb"}"""))
|
||||
assertEquals(ImeEvent.Blur, parseImeEvent("""{"type":"ime.blur"}"""))
|
||||
}
|
||||
|
||||
// ---- focus / refocus ----
|
||||
|
||||
@Test
|
||||
fun parsesFocusWithAllFields() {
|
||||
val event =
|
||||
parseImeEvent(
|
||||
"""{"type":"ime.focus","inputType":"email","enterKeyHint":"send","multiline":true,
|
||||
"readOnly":true,"text":"gm","selStart":1,"selEnd":2,
|
||||
"geom":{"l":1,"t":2,"r":3,"b":4,"sx":5,"sb":6,"ex":7,"eb":8,"vw":360}}""",
|
||||
) as ImeEvent.Focus
|
||||
assertEquals("email", event.inputType)
|
||||
assertEquals("send", event.enterKeyHint)
|
||||
assertTrue(event.multiline)
|
||||
assertTrue(event.readOnly)
|
||||
assertEquals("gm", event.text)
|
||||
assertEquals(1, event.selStart)
|
||||
assertEquals(2, event.selEnd)
|
||||
assertEquals(360f, event.geometry!!.viewportWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusDefaultsEveryAbsentField() {
|
||||
val event = parseImeEvent("""{"type":"ime.focus"}""") as ImeEvent.Focus
|
||||
assertEquals("text", event.inputType)
|
||||
assertEquals("", event.enterKeyHint)
|
||||
assertFalse(event.multiline)
|
||||
assertFalse(event.readOnly)
|
||||
assertEquals("", event.text)
|
||||
assertEquals(0, event.selStart)
|
||||
assertEquals(0, event.selEnd)
|
||||
assertNull(event.geometry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refocusWrapsAFocus() {
|
||||
val event = parseImeEvent("""{"type":"ime.refocus","inputType":"url","text":"a"}""") as ImeEvent.ReFocus
|
||||
assertEquals("url", event.focus.inputType)
|
||||
assertEquals("a", event.focus.text)
|
||||
}
|
||||
|
||||
// ---- state / pagesel / scroll / carettap ----
|
||||
|
||||
@Test
|
||||
fun parsesState() {
|
||||
val event = parseImeEvent("""{"type":"ime.state","text":"abc","selStart":1,"selEnd":3}""") as ImeEvent.State
|
||||
assertEquals("abc", event.text)
|
||||
assertEquals(1, event.selStart)
|
||||
assertEquals(3, event.selEnd)
|
||||
assertNull(event.geometry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateToleratesExplicitNullGeom() {
|
||||
val event = parseImeEvent("""{"type":"ime.state","text":"a","selStart":0,"selEnd":0,"geom":null}""") as ImeEvent.State
|
||||
assertNull(event.geometry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesPageSelection() {
|
||||
val event =
|
||||
parseImeEvent(
|
||||
"""{"type":"ime.pagesel","active":true,"text":"copied",
|
||||
"geom":{"l":0,"t":0,"r":10,"b":10,"sx":0,"sb":10,"ex":10,"eb":10,"vw":360}}""",
|
||||
) as ImeEvent.PageSelection
|
||||
assertTrue(event.active)
|
||||
assertEquals("copied", event.text)
|
||||
assertEquals(10f, event.geometry!!.right)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesScroll() {
|
||||
assertTrue((parseImeEvent("""{"type":"ime.scroll","active":true}""") as ImeEvent.Scroll).active)
|
||||
assertFalse((parseImeEvent("""{"type":"ime.scroll"}""") as ImeEvent.Scroll).active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesCaretTap() {
|
||||
val event =
|
||||
parseImeEvent(
|
||||
"""{"type":"ime.carettap","geom":{"l":1,"t":2,"r":3,"b":4,"sx":1,"sb":4,"ex":3,"eb":4,"vw":360}}""",
|
||||
) as ImeEvent.CaretTap
|
||||
assertEquals(1f, event.geometry!!.left)
|
||||
assertNull((parseImeEvent("""{"type":"ime.carettap"}""") as ImeEvent.CaretTap).geometry)
|
||||
}
|
||||
|
||||
// ---- geometry ----
|
||||
|
||||
@Test
|
||||
fun geometryNullForAbsentObject() {
|
||||
assertNull(parseSelectionGeometry(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun geometryDefaultsAbsentFieldsToZero() {
|
||||
val g = geom("""{}""")!!
|
||||
assertEquals(0f, g.left)
|
||||
assertEquals(0f, g.viewportWidth)
|
||||
assertFalse(g.isRange)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun geometryCaretFieldsAreNullOnlyWhenAbsent() {
|
||||
val without = geom("""{"l":1}""")!!
|
||||
assertNull(without.caretX)
|
||||
assertNull(without.caretTop)
|
||||
assertNull(without.caretBottom)
|
||||
|
||||
val with = geom("""{"cx":10.5,"ct":20,"cb":30,"rng":true}""")!!
|
||||
assertEquals(10.5f, with.caretX)
|
||||
assertEquals(20f, with.caretTop)
|
||||
assertEquals(30f, with.caretBottom)
|
||||
assertTrue(with.isRange)
|
||||
}
|
||||
|
||||
// ---- coercion behavior for mistyped fields (deliberate: total accessors, no throwing) ----
|
||||
|
||||
@Test
|
||||
fun mistypedFieldsDegradeToDefaultsInsteadOfThrowing() {
|
||||
val event =
|
||||
parseImeEvent(
|
||||
// selStart as a numeric string parses; selEnd as a fraction does NOT truncate
|
||||
// (unlike org.json's optInt) — it falls back to 0; text as a number coerces to
|
||||
// its literal text; readOnly as the string "true" parses as a boolean.
|
||||
"""{"type":"ime.focus","text":7,"selStart":"5","selEnd":5.7,"readOnly":"true","geom":[1,2]}""",
|
||||
) as ImeEvent.Focus
|
||||
assertEquals("7", event.text)
|
||||
assertEquals(5, event.selStart)
|
||||
assertEquals(0, event.selEnd)
|
||||
assertTrue(event.readOnly)
|
||||
assertNull(event.geometry) // an array where an object belongs is treated as absent
|
||||
}
|
||||
|
||||
// ---- host → page envelopes ----
|
||||
|
||||
@Test
|
||||
fun resyncRequestSendsTheBareEnvelope() {
|
||||
var sent: String? = null
|
||||
val bridge =
|
||||
object : EmbeddedImeBridge {
|
||||
override var onImeEvent: ((ImeEvent) -> Unit)? = null
|
||||
|
||||
override fun sendImeOp(json: String) {
|
||||
sent = json
|
||||
}
|
||||
}
|
||||
bridge.requestImeResync()
|
||||
assertEquals("""{"type":"ime.resync"}""", sent)
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.notifications.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.RepliesAndReactionsKinds2
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.NotificationsPerKeyKinds2
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the full NIP-34 collaboration surface across the four notification-plumbing
|
||||
* lists that have to move together — miss any one and either the event is never
|
||||
* asked for, or it arrives but never renders:
|
||||
*
|
||||
* 1. [NotificationsPerKeyKinds2] — the inbox-relay `#p`=me subscription. If the
|
||||
* kind isn't listed here, the event never reaches this device unless it
|
||||
* happens to arrive through some other subscription (a repo thread view,
|
||||
* home-feed spillover). This is what gives you a merge notification when
|
||||
* you close the app and come back an hour later.
|
||||
* 2. [RepliesAndReactionsKinds2] — the `#e`=<targetId> engagement subscription
|
||||
* that fires when a patch/PR/issue row is on screen. This is what makes
|
||||
* [com.vitorpamplona.amethyst.model.GitStatusIndex] actually see status
|
||||
* events so the closed/merged pill can render on the repo page.
|
||||
* 3. [NotificationFeedFilter.NOTIFICATION_KINDS] — the in-app Notifications tab
|
||||
* kind gate. Without this, the event arrives from (1), sits in LocalCache,
|
||||
* and never renders a row.
|
||||
* 4. [NotificationDispatcher.NOTIFICATION_KINDS] — the push/tray observer's
|
||||
* kind gate. Without this, the event arrives from (1), sits in LocalCache,
|
||||
* and never fires a system notification.
|
||||
*
|
||||
* A regression on any of (1)–(4) silently drops one specific transition
|
||||
* (comment on your PR, PR merged, patch closed, …) and there is no other
|
||||
* place to catch it.
|
||||
*/
|
||||
class Nip34NotificationCoverageTest {
|
||||
/**
|
||||
* Every NIP-34 event that participants care about — patch, issue, PR,
|
||||
* PR update (revision), legacy git-reply comment (1622, deprecated by
|
||||
* NIP-22 but still in the wild), and the four status transitions.
|
||||
* A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles
|
||||
* modern comments through its own separate wiring.
|
||||
*/
|
||||
private val nip34ParticipantKinds =
|
||||
setOf(
|
||||
GitPatchEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
GitReplyEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every NIP-34 participant kind is subscribed on inbox relays`() {
|
||||
val missing = nip34ParticipantKinds - NotificationsPerKeyKinds2.toSet()
|
||||
assertTrue(
|
||||
"NIP-34 kinds $missing are missing from NotificationsPerKeyKinds2. Without a " +
|
||||
"`#p`=me subscription for these kinds, the event never lands on the device — " +
|
||||
"so no merge/close notification can ever fire.",
|
||||
missing.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every NIP-34 participant kind renders on the Android notifications tab`() {
|
||||
val missing = nip34ParticipantKinds - NotificationFeedFilter.NOTIFICATION_KINDS.toSet()
|
||||
assertTrue(
|
||||
"NIP-34 kinds $missing are missing from NotificationFeedFilter.NOTIFICATION_KINDS. " +
|
||||
"The event arrives from the p-tag subscription but the kind gate drops it " +
|
||||
"before it can render on the Notifications tab.",
|
||||
missing.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every NIP-34 participant kind fires a push notification`() {
|
||||
val missing = nip34ParticipantKinds - NotificationDispatcher.NOTIFICATION_KINDS
|
||||
assertTrue(
|
||||
"NIP-34 kinds $missing are missing from NotificationDispatcher.NOTIFICATION_KINDS. " +
|
||||
"The event arrives and renders in-app but no system-tray push fires — " +
|
||||
"the user has to open the app to see it.",
|
||||
missing.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A status/update event's discovery path when a repo or PR is on screen: the
|
||||
* [`e`=<targetId>][RepliesAndReactionsKinds2] engagement subscription. Without
|
||||
* this the closed/merged pill on a repo listing can never populate — the
|
||||
* status event's only other route to the device is the `#p`=me subscription,
|
||||
* which only fires for accounts that were pre-tagged as participants.
|
||||
* Patches/issues/PRs are self-anchored (they ARE the target, not events
|
||||
* about the target), so they are intentionally NOT expected here.
|
||||
*/
|
||||
@Test
|
||||
fun `status and PR-update kinds are pulled by the engagement subscription`() {
|
||||
val threadedActivityKinds =
|
||||
setOf(
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
GitReplyEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
)
|
||||
val missing = threadedActivityKinds - RepliesAndReactionsKinds2.toSet()
|
||||
assertTrue(
|
||||
"Kinds $missing are missing from RepliesAndReactionsKinds2. When a repo/PR row is " +
|
||||
"on screen the app fetches replies + reactions targeting the visible events — " +
|
||||
"this is where GitStatusIndex gets its data. Missing kinds mean the closed/" +
|
||||
"merged pill on a repo listing never populates for anyone who isn't a p-tagged " +
|
||||
"participant of the PR.",
|
||||
missing.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.wallet
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.TransactionRowLabels
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TransactionRowLabelsTest {
|
||||
private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
|
||||
|
||||
/**
|
||||
* The bug this class exists for. Wallets send `"description": ""` for a payment
|
||||
* with no memo; the old row did `tx.description ?: fallback`, which only catches
|
||||
* null, so it rendered an empty Text — a line with the height of a real one and
|
||||
* nothing in it. Outgoing rows looked like a bare arrow and a date.
|
||||
*/
|
||||
@Test
|
||||
fun aDescriptionWithNothingInItFallsBackToTheDirection() {
|
||||
// Empty and whitespace are what wallets actually send for a payment with no
|
||||
// memo; absent is the spec-clean form. All three must reach the fallback.
|
||||
listOf("", " ", null).forEach { description ->
|
||||
val labels = TransactionRowLabels.resolve(NwcTransaction(type = "outgoing", description = description), "Sent")
|
||||
|
||||
assertEquals("description=<$description>", TransactionRowLabels.Title.Literal("Sent"), labels.title)
|
||||
assertNull("description=<$description>", labels.subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRealDescriptionIsTheTitle() {
|
||||
val labels = TransactionRowLabels.resolve(NwcTransaction(type = "outgoing", description = "Coffee"), "Sent")
|
||||
assertEquals(TransactionRowLabels.Title.Literal("Coffee"), labels.title)
|
||||
assertNull(labels.subtitle)
|
||||
}
|
||||
|
||||
/** An outgoing zap resolves the payee from the zap request's `p` tag. */
|
||||
@Test
|
||||
fun anOutgoingZapNamesThePayee() {
|
||||
val labels =
|
||||
TransactionRowLabels.resolve(
|
||||
NwcTransaction(
|
||||
type = "outgoing",
|
||||
description = "",
|
||||
metadata =
|
||||
mapOf(
|
||||
"recipient_data" to mapOf("identifier" to "user@domain.com"),
|
||||
"nostr" to
|
||||
mapOf(
|
||||
"pubkey" to "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f",
|
||||
"content" to "great post",
|
||||
"tags" to listOf(listOf("p", recipientHex)),
|
||||
),
|
||||
),
|
||||
),
|
||||
"Sent",
|
||||
)
|
||||
|
||||
assertEquals(TransactionRowLabels.Title.User(recipientHex, "user@domain.com"), labels.title)
|
||||
assertEquals("great post", labels.subtitle)
|
||||
}
|
||||
|
||||
/** With no zap request, the lightning address alone still labels the row. */
|
||||
@Test
|
||||
fun theLeanPairAloneStillNamesThePayee() {
|
||||
val labels =
|
||||
TransactionRowLabels.resolve(
|
||||
NwcTransaction(
|
||||
type = "outgoing",
|
||||
description = "",
|
||||
metadata = mapOf("recipient_data" to mapOf("identifier" to "user@domain.com")),
|
||||
),
|
||||
"Sent",
|
||||
)
|
||||
|
||||
assertEquals(TransactionRowLabels.Title.Literal("user@domain.com"), labels.title)
|
||||
// Named but undescribed: the second line says what the row was.
|
||||
assertEquals("Sent", labels.subtitle)
|
||||
}
|
||||
|
||||
/** Incoming rows keep working off the payer, which is what they did before. */
|
||||
@Test
|
||||
fun anIncomingZapStillNamesTheSender() {
|
||||
val labels =
|
||||
TransactionRowLabels.resolve(
|
||||
NwcTransaction(
|
||||
type = "incoming",
|
||||
description = "Test",
|
||||
metadata = mapOf("nostr" to mapOf("pubkey" to recipientHex, "content" to "Test")),
|
||||
),
|
||||
"Received",
|
||||
)
|
||||
|
||||
assertTrue(labels.title is TransactionRowLabels.Title.User)
|
||||
// The comment merely repeats the description, so it is not shown twice.
|
||||
assertEquals("Test", labels.subtitle)
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -20,17 +20,18 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.feeds.custom
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.intOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.longOrNull
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.intOrNull as primitiveIntOrNull
|
||||
|
||||
object FeedDefinitionSerializer {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -65,8 +66,8 @@ object FeedDefinitionSerializer {
|
||||
val id = node.string("id") ?: return null
|
||||
val name = node.string("name") ?: return null
|
||||
val emoji = node.string("emoji") ?: ""
|
||||
val pinned = node.bool("pinned") ?: false
|
||||
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
|
||||
val pinned = node.booleanOrNull("pinned") ?: false
|
||||
val pinOrder = node.intOrNull("pinOrder") ?: Int.MAX_VALUE
|
||||
val refreshMode =
|
||||
node.string("refreshMode")?.let {
|
||||
try {
|
||||
@@ -75,7 +76,7 @@ object FeedDefinitionSerializer {
|
||||
RefreshMode.LIVE_STREAM
|
||||
}
|
||||
} ?: RefreshMode.LIVE_STREAM
|
||||
val createdAt = node.long("createdAt") ?: 0L
|
||||
val createdAt = node.longOrNull("createdAt") ?: 0L
|
||||
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
|
||||
|
||||
return FeedDefinition(
|
||||
@@ -158,7 +159,7 @@ object FeedDefinitionSerializer {
|
||||
|
||||
"people_list" -> {
|
||||
FeedSource.PeopleList(
|
||||
kind = node.int("kind") ?: 30000,
|
||||
kind = node.intOrNull("kind") ?: 30000,
|
||||
pubkey = node.string("pubkey") ?: return null,
|
||||
dTag = node.string("dTag") ?: return null,
|
||||
)
|
||||
@@ -166,7 +167,7 @@ object FeedDefinitionSerializer {
|
||||
|
||||
"interest_set" -> {
|
||||
FeedSource.InterestSet(
|
||||
kind = node.int("kind") ?: 30015,
|
||||
kind = node.intOrNull("kind") ?: 30015,
|
||||
pubkey = node.string("pubkey") ?: return null,
|
||||
dTag = node.string("dTag") ?: return null,
|
||||
)
|
||||
@@ -174,7 +175,7 @@ object FeedDefinitionSerializer {
|
||||
|
||||
"dvm" -> {
|
||||
FeedSource.DVM(
|
||||
kind = node.int("kind") ?: 31990,
|
||||
kind = node.intOrNull("kind") ?: 31990,
|
||||
pubkey = node.string("pubkey") ?: return null,
|
||||
dTag = node.string("dTag") ?: return null,
|
||||
)
|
||||
@@ -194,14 +195,10 @@ object FeedDefinitionSerializer {
|
||||
|
||||
private fun stringArray(values: Iterable<String>): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
|
||||
|
||||
// Deliberately stricter than the shared stringOrNull: a persisted feed field must be an actual
|
||||
// quoted string — a number or boolean here is a corrupt record, not a value to coerce.
|
||||
private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
|
||||
|
||||
private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
|
||||
|
||||
private fun JsonObject.stringList(key: String) =
|
||||
(this[key] as? JsonArray)
|
||||
?.map { (it as? JsonPrimitive)?.content.orEmpty() }
|
||||
@@ -209,6 +206,6 @@ object FeedDefinitionSerializer {
|
||||
|
||||
private fun JsonObject.intList(key: String) =
|
||||
(this[key] as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.primitiveIntOrNull }
|
||||
?.toImmutableList()
|
||||
}
|
||||
|
||||
+15
@@ -38,7 +38,12 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -73,6 +78,16 @@ val RepliesAndReactionsKinds2 =
|
||||
NIP90StatusEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
GitReplyEvent.KIND,
|
||||
// NIP-34 PR revision (1619) and status events (1630/1631/1632/1633).
|
||||
// Rooted at the target patch/PR/issue via a `root`-marked `e` tag, so
|
||||
// an `e=<targetId>` engagement fetch surfaces the PR's revision chain
|
||||
// and every open/applied/closed/draft transition — the signal
|
||||
// GitStatusIndex needs to answer isClosedOrResolved() for repo rows.
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
PollResponseEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
)
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.commons.util
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
// Total accessors for ad-hoc JSON trees (kotlinx.serialization) — bridge envelopes, persisted
|
||||
// blobs, evaluateJavascript results. Every field is optional and possibly attacker-controlled,
|
||||
// so each accessor degrades to null instead of throwing on an absent, JSON-null, or mistyped
|
||||
// value. A codec that WANTS to reject malformed input (e.g. NappletProtocolJson) should keep
|
||||
// throwing accessors instead of these.
|
||||
|
||||
/** Parses [raw] as a JSON object, or null when it is malformed or not an object. */
|
||||
fun parseJsonObjectOrNull(raw: String): JsonObject? = runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull()
|
||||
|
||||
/**
|
||||
* The primitive at [key] rendered as a string, or null when absent, JSON-null, or not a
|
||||
* primitive. Numbers and booleans coerce to their literal text (org.json `optString` style);
|
||||
* use a `isString`-guarded read instead where a quoted string must be told apart from them.
|
||||
*/
|
||||
fun JsonObject.stringOrNull(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
/** The primitive at [key] as an Int, or null when absent or not parseable as one. */
|
||||
fun JsonObject.intOrNull(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
|
||||
|
||||
/** The primitive at [key] as a Long, or null when absent or not parseable as one. */
|
||||
fun JsonObject.longOrNull(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
|
||||
|
||||
/** The primitive at [key] as a Double, or null when absent or not parseable as one. */
|
||||
fun JsonObject.doubleOrNull(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull
|
||||
|
||||
/** The primitive at [key] as a Boolean, or null when absent or not parseable as one. */
|
||||
fun JsonObject.booleanOrNull(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
/** The nested object at [key], or null when absent or not an object. */
|
||||
fun JsonObject.objectOrNull(key: String): JsonObject? = this[key] as? JsonObject
|
||||
|
||||
/** A copy of this object with [key] set to [value] ([JsonObject] is immutable). */
|
||||
fun JsonObject.withString(
|
||||
key: String,
|
||||
value: String,
|
||||
): JsonObject = JsonObject(this + (key to JsonPrimitive(value)))
|
||||
@@ -85,20 +85,54 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "Bardesss",
|
||||
"user": "vitorpamplona",
|
||||
"languages": [
|
||||
"Dutch"
|
||||
"Arabic, Saudi Arabia",
|
||||
"Bengali",
|
||||
"Chinese Simplified",
|
||||
"Chinese Simplified, Singapore",
|
||||
"Chinese Traditional",
|
||||
"Chinese Traditional, Hong Kong",
|
||||
"Czech",
|
||||
"Dutch",
|
||||
"Esperanto",
|
||||
"Finnish",
|
||||
"French",
|
||||
"French, Canada",
|
||||
"German",
|
||||
"Greek",
|
||||
"Hindi",
|
||||
"Hungarian",
|
||||
"Indonesian",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Latvian",
|
||||
"Persian",
|
||||
"Polish",
|
||||
"Portuguese",
|
||||
"Portuguese, Brazilian",
|
||||
"Russian",
|
||||
"Rսssian, Սkraine",
|
||||
"Serbian (Cyrillic)",
|
||||
"Slovenian",
|
||||
"Spanish",
|
||||
"Spanish, Mexico",
|
||||
"Spanish, United States",
|
||||
"Swahili, Kenya",
|
||||
"Swedish",
|
||||
"Tamil",
|
||||
"Thai",
|
||||
"Turkish",
|
||||
"Ukrainian",
|
||||
"Uzbek",
|
||||
"Vietnamese"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "vitorpamplona",
|
||||
"user": "Bardesss",
|
||||
"languages": [
|
||||
"Czech",
|
||||
"German",
|
||||
"Polish",
|
||||
"Portuguese, Brazilian",
|
||||
"Slovenian",
|
||||
"Swedish"
|
||||
"Dutch"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -44,6 +44,9 @@ dependencies {
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// Tree-level JSON for the bridge/broker envelopes (no @Serializable codegen, so no plugin needed).
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Provider side of the cross-process UI embedding: hosts the browser WebView in this keyless
|
||||
// `:napplet` process and ships its rendered surface to the main app via SurfaceControlViewHost.
|
||||
implementation(libs.androidx.privacysandbox.ui.core)
|
||||
|
||||
+7
-5
@@ -63,8 +63,11 @@ import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.Executor
|
||||
@@ -616,13 +619,13 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
if (!isMainFrame) return
|
||||
bridgeReplyProxy = replyProxy
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val envelope = parseJsonObjectOrNull(raw) ?: return
|
||||
|
||||
val scheme = sourceOrigin.scheme ?: return
|
||||
val host = sourceOrigin.host ?: return
|
||||
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
|
||||
|
||||
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
|
||||
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = replyMessenger
|
||||
@@ -667,8 +670,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
NappletIpc.MSG_RESPONSE -> {
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
|
||||
result.put("id", id)
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
|
||||
bridgeReplyProxy?.postMessage(result.toString())
|
||||
}
|
||||
NappletIpc.MSG_PUSH -> {
|
||||
|
||||
+8
-6
@@ -55,8 +55,11 @@ import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
@@ -591,10 +594,10 @@ class NappletBrowserService : Service() {
|
||||
if (!isMainFrame) return
|
||||
tab.bridgeReplyProxy = replyProxy
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val envelope = parseJsonObjectOrNull(raw) ?: return
|
||||
|
||||
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
|
||||
if (envelope.optString("type").startsWith("ime.")) {
|
||||
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
|
||||
val reply =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply {
|
||||
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
|
||||
@@ -607,7 +610,7 @@ class NappletBrowserService : Service() {
|
||||
val host = sourceOrigin.host ?: return
|
||||
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
|
||||
|
||||
val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
@@ -716,8 +719,7 @@ class NappletBrowserService : Service() {
|
||||
NappletIpc.MSG_RESPONSE -> {
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
|
||||
result.put("id", id)
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
|
||||
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
|
||||
}
|
||||
NappletIpc.MSG_PUSH -> {
|
||||
|
||||
+5
-4
@@ -25,8 +25,9 @@ import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Declared-favicon capture for the sandboxed browser WebViews, complementing
|
||||
@@ -111,9 +112,9 @@ internal object NappletFaviconSniffer {
|
||||
/** `state` to base64 payload, or null when the page has not produced a result for this seq yet. */
|
||||
private fun parse(raw: String?): Pair<String, String>? {
|
||||
if (raw == null || raw == "null") return null
|
||||
val json = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val state = json.optString("state").ifBlank { return null }
|
||||
return state to json.optString("data")
|
||||
val json = parseJsonObjectOrNull(raw) ?: return null
|
||||
val state = json.stringOrNull("state")?.ifBlank { null } ?: return null
|
||||
return state to json.stringOrNull("data").orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+16
-13
@@ -64,6 +64,10 @@ import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import com.vitorpamplona.amethyst.napplethost.R
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
|
||||
@@ -78,7 +82,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.Executor
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
@@ -744,17 +748,17 @@ class NappletHostActivity : ComponentActivity() {
|
||||
val raw = message.data ?: return
|
||||
// The applet sends a full upstream envelope {type, id, ...}; we forward it verbatim and
|
||||
// correlate on its id. The broker reads `type` to decode and to build the .result reply.
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val envelope = parseJsonObjectOrNull(raw) ?: return
|
||||
|
||||
// Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no
|
||||
// actionId, so the binding is removed here from the envelope itself).
|
||||
if (envelope.optString("type") == "keys.unregisterAction") {
|
||||
envelope.optString("actionId").takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) }
|
||||
if (envelope.stringOrNull("type") == "keys.unregisterAction") {
|
||||
envelope.stringOrNull("actionId")?.takeIf { it.isNotEmpty() }?.let { keyActions.unregister(it) }
|
||||
}
|
||||
|
||||
// Fire-and-forget messages (inc.emit, keys.unregisterAction) have no id; synthesize one so
|
||||
// they still reach the broker. Any reply is harmless — the applet has nothing to correlate.
|
||||
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
|
||||
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
|
||||
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
@@ -791,13 +795,12 @@ class NappletHostActivity : ComponentActivity() {
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
|
||||
// payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim.
|
||||
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
|
||||
result.put("id", id)
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
|
||||
// The broker authorized a keyboard action: bind the honored key combo so dispatchKeyEvent
|
||||
// can fire it. Only ok'd registrations bind (a denied KEYS request never reaches here).
|
||||
if (result.optString("type") == "keys.registerAction.result" && result.optBoolean("ok")) {
|
||||
val actionId = result.optString("actionId")
|
||||
if (actionId.isNotEmpty()) keyActions.register(actionId, result.optString("binding").ifEmpty { null })
|
||||
if (result.stringOrNull("type") == "keys.registerAction.result" && result.booleanOrNull("ok") == true) {
|
||||
val actionId = result.stringOrNull("actionId").orEmpty()
|
||||
if (actionId.isNotEmpty()) keyActions.register(actionId, result.stringOrNull("binding")?.ifEmpty { null })
|
||||
}
|
||||
notifyIfSensitive(result)
|
||||
bridgeReplyProxy?.postMessage(result.toString())
|
||||
@@ -989,10 +992,10 @@ class NappletHostActivity : ComponentActivity() {
|
||||
* Surfaces an "allow always" capability acting on the user's behalf, so a granted RELAY/UPLOAD/VALUE
|
||||
* op can never run completely silently. Read-only ops (identity/storage/resource) don't toast.
|
||||
*/
|
||||
private fun notifyIfSensitive(result: JSONObject) {
|
||||
if (!result.optBoolean("ok")) return
|
||||
private fun notifyIfSensitive(result: JsonObject) {
|
||||
if (result.booleanOrNull("ok") != true) return
|
||||
val message =
|
||||
when (result.optString("type")) {
|
||||
when (result.stringOrNull("type")) {
|
||||
"relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle())
|
||||
"upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle())
|
||||
"value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle())
|
||||
|
||||
+12
-9
@@ -58,11 +58,15 @@ import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
||||
import com.vitorpamplona.amethyst.commons.util.withString
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
@@ -583,10 +587,10 @@ class NappletHostService : Service() {
|
||||
tab.bridgeReplyProxy = replyProxy
|
||||
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val envelope = parseJsonObjectOrNull(raw) ?: return
|
||||
|
||||
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
|
||||
if (envelope.optString("type").startsWith("ime.")) {
|
||||
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
|
||||
val reply =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
|
||||
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
|
||||
@@ -595,7 +599,7 @@ class NappletHostService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = tab.replyMessenger
|
||||
@@ -630,8 +634,7 @@ class NappletHostService : Service() {
|
||||
NappletIpc.MSG_RESPONSE -> {
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
|
||||
result.put("id", id)
|
||||
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
|
||||
notifyIfSensitive(tab, result)
|
||||
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
|
||||
}
|
||||
@@ -647,11 +650,11 @@ class NappletHostService : Service() {
|
||||
/** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */
|
||||
private fun notifyIfSensitive(
|
||||
tab: NappletTab,
|
||||
result: JSONObject,
|
||||
result: JsonObject,
|
||||
) {
|
||||
if (!result.optBoolean("ok")) return
|
||||
if (result.booleanOrNull("ok") != true) return
|
||||
val notice =
|
||||
when (result.optString("type")) {
|
||||
when (result.stringOrNull("type")) {
|
||||
"relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED
|
||||
"upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED
|
||||
"value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID
|
||||
|
||||
+1
-13
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields
|
||||
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
|
||||
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
|
||||
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
|
||||
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyMap
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
@@ -45,7 +46,6 @@ import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -63,18 +63,6 @@ import kotlinx.serialization.json.put
|
||||
* (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — so native targets parse the same wire shapes.
|
||||
*/
|
||||
|
||||
private fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is String -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } }
|
||||
is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
|
||||
private fun JsonObject.stringOrNull(key: String): String? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
|
||||
|
||||
private fun JsonObject.longOrNull(key: String): Long? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.longOrNull }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.core
|
||||
|
||||
/**
|
||||
* JSON that is already serialized and must reach the wire as-is.
|
||||
*
|
||||
* Used where the exact BYTES matter and not merely the value. The case that
|
||||
* forced it: NIP-57 sets a zap invoice's `description_hash` to the sha256 of the
|
||||
* raw zap-request JSON the LNURL callback received, so a wallet binding a stored
|
||||
* zap request to the invoice it labels hashes those same bytes. Handing the
|
||||
* serializer a decomposed `Map` and hoping it reassembles them identically makes
|
||||
* that binding depend on key order, escaping and number formatting agreeing by
|
||||
* coincidence — and it fails silently, as an unlabelled row, when they do not.
|
||||
*
|
||||
* Both backends emit the string verbatim: Jackson via `writeRawValue`, kotlinx via
|
||||
* `JsonUnquotedLiteral`. [json] MUST already be well-formed JSON; nothing
|
||||
* validates it, and an invalid value corrupts the whole document.
|
||||
*/
|
||||
data class RawJson(
|
||||
val json: String,
|
||||
) {
|
||||
override fun toString() = json
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.kotlinSerialization
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.RawJson
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonUnquotedLiteral
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Encodes an untyped `Any?` tree — the shape several Nostr RPCs carry as a free-form
|
||||
* `Map<String, Any?>` — into a [JsonElement].
|
||||
*
|
||||
* `Json.encodeToJsonElement` CANNOT do this: it resolves a serializer from the STATIC
|
||||
* type, and `Any` has none, so it throws `SerializationException: Serializer for class
|
||||
* 'Any' is not found` at runtime for every populated map. This walks the value instead.
|
||||
*
|
||||
* DECLARED AT nip01Core LEVEL, beside the other kotlinx serializers, because [RawJson]
|
||||
* is: it is registered globally on the Jackson side, so a per-NIP copy of this function
|
||||
* would leave the kotlinx backend supporting raw JSON in some packages and silently
|
||||
* quoting it into a string in others — the exact corruption [RawJson] exists to prevent.
|
||||
*/
|
||||
fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> JsonNull
|
||||
is RawJson -> JsonUnquotedLiteral(value.json)
|
||||
is JsonElement -> value
|
||||
is String -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } }
|
||||
is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
|
||||
is Array<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
+17
@@ -122,6 +122,21 @@ val DEFAULT_ELECTRUMX_SERVERS =
|
||||
// self-signed peer above is unreachable (e.g. corporate networks that
|
||||
// strip unknown CAs but allow LE).
|
||||
ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false),
|
||||
// electrumx2.testls.space — redundancy endpoint for the testls.space
|
||||
// operator, on the same box as relay.testls.bit (23.158.233.10) but
|
||||
// terminating TLS at nginx with a publicly-trusted Let's Encrypt cert
|
||||
// (CN=electrumx2.testls.space, issuer LE YE1) instead of the self-signed
|
||||
// relay.testls.bit cert served on the standard ports. usePinnedTrustStore
|
||||
// is left at the default (false) because the system trust store is
|
||||
// sufficient.
|
||||
//
|
||||
// Port 50012 is the TCP+TLS endpoint (what this client uses). The same
|
||||
// nginx vhost also exposes WSS on port 50014 for browser-based Nostr
|
||||
// clients that want to do Namecoin NIP-05 lookups without a backend
|
||||
// proxy — browsers refuse WSS to self-signed certs, so the LE cert on
|
||||
// electrumx2 makes it the first browser-viable public Namecoin
|
||||
// ElectrumX endpoint alongside electrum.nmc.ethicnology.com.
|
||||
ElectrumxServer("electrumx2.testls.space", 50012, useSsl = true, usePinnedTrustStore = false),
|
||||
// Note: no bare-IP companion entry for electrum.nmc.ethicnology.com.
|
||||
// Unlike the 46.229.238.187 / 23.158.233.10 peers above (which use
|
||||
// usePinnedTrustStore=true and DER-SHA256 pinning that doesn't care
|
||||
@@ -157,4 +172,6 @@ val TOR_ELECTRUMX_SERVERS =
|
||||
ElectrumxServer("23.158.233.10", 50002, useSsl = true, usePinnedTrustStore = true),
|
||||
// electrum.nmc.ethicnology.com — public LE-cert ElectrumX. See clearnet list above.
|
||||
ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false),
|
||||
// electrumx2.testls.space — LE-cert redundancy endpoint. See clearnet list above.
|
||||
ElectrumxServer("electrumx2.testls.space", 50012, useSsl = true, usePinnedTrustStore = false),
|
||||
)
|
||||
|
||||
+20
-8
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -47,19 +48,28 @@ class NwcInfoEvent(
|
||||
// NIP-47 carries the schemes/types as a single space-separated string in one
|
||||
// tag value (e.g. ["encryption", "nip44_v2 nip04"]). Split on whitespace so we
|
||||
// return individual tokens, while still tolerating a multi-element tag.
|
||||
fun encryptionSchemes() =
|
||||
private fun spaceSeparatedTag(parse: (Array<String>) -> List<String>?) =
|
||||
tags
|
||||
.mapNotNull(EncryptionTag::parse)
|
||||
.mapNotNull(parse)
|
||||
.flatten()
|
||||
.flatMap { it.split(" ") }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
fun notificationTypes() =
|
||||
tags
|
||||
.mapNotNull(NotificationsTag::parse)
|
||||
.flatten()
|
||||
.flatMap { it.split(" ") }
|
||||
.filter { it.isNotBlank() }
|
||||
fun encryptionSchemes() = spaceSeparatedTag(EncryptionTag::parse)
|
||||
|
||||
fun notificationTypes() = spaceSeparatedTag(NotificationsTag::parse)
|
||||
|
||||
/** The optional NWC extension specs this wallet advertises (eg. `["05", "06"]`). */
|
||||
fun extensions() = spaceSeparatedTag(ExtensionsTag::parse)
|
||||
|
||||
/**
|
||||
* Whether the wallet advertises a given NWC extension spec.
|
||||
*
|
||||
* A wallet that says nothing reads as **no**. That direction is deliberate:
|
||||
* the caller is deciding whether to send something the wallet may not
|
||||
* understand, so silence must not be read as permission.
|
||||
*/
|
||||
fun supportsExtension(id: String) = extensions().contains(id)
|
||||
|
||||
companion object {
|
||||
const val KIND = 13194
|
||||
@@ -68,11 +78,13 @@ class NwcInfoEvent(
|
||||
capabilities: List<String>,
|
||||
encryptionSchemes: List<String>? = null,
|
||||
notificationTypes: List<String>? = null,
|
||||
extensions: List<String>? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<NwcInfoEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) {
|
||||
encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) }
|
||||
notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) }
|
||||
extensions?.let { addUnique(ExtensionsTag.assemble(it)) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
|
||||
@@ -55,7 +56,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -65,7 +65,6 @@ import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -148,7 +147,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
buildJsonObject {
|
||||
params.invoice?.let { put("invoice", it) }
|
||||
params.amount?.let { put("amount", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializePayParams(params: PayParams): JsonObject =
|
||||
@@ -156,14 +155,14 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
params.payment?.let { put("payment", it) }
|
||||
params.amount?.let { put("amount", it) }
|
||||
params.payer_note?.let { put("payer_note", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializeReceiveParams(params: ReceiveParams): JsonObject =
|
||||
buildJsonObject {
|
||||
params.amount?.let { put("amount", it) }
|
||||
params.description?.let { put("description", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject =
|
||||
@@ -194,7 +193,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
params.description?.let { put("description", it) }
|
||||
params.description_hash?.let { put("description_hash", it) }
|
||||
params.expiry?.let { put("expiry", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializeLookupInvoiceParams(params: LookupInvoiceParams): JsonObject =
|
||||
@@ -234,7 +233,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
params.budget_renewal?.let { put("budget_renewal", it) }
|
||||
params.expires_at?.let { put("expires_at", it) }
|
||||
params.isolated?.let { put("isolated", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializeMakeHoldInvoiceParams(params: MakeHoldInvoiceParams): JsonObject =
|
||||
|
||||
+3
-4
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
|
||||
@@ -47,7 +48,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -56,7 +56,6 @@ import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -226,7 +225,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
|
||||
result.notifications?.let { notifications ->
|
||||
put("notifications", buildJsonArray { notifications.forEach { add(it) } })
|
||||
}
|
||||
result.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
result.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
result.lud16?.let { put("lud16", it) }
|
||||
}
|
||||
|
||||
@@ -373,7 +372,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
|
||||
transaction.expires_at?.let { put("expires_at", it) }
|
||||
transaction.settled_at?.let { put("settled_at", it) }
|
||||
transaction.settle_deadline?.let { put("settle_deadline", it) }
|
||||
transaction.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
transaction.metadata?.let { put("metadata", anyToJsonElement(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -65,6 +65,18 @@ class NwcTransaction(
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
) {
|
||||
fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata)
|
||||
|
||||
/**
|
||||
* The description, or null when the wallet had nothing to say.
|
||||
*
|
||||
* Wallets send `"description": ""` for a payment with no memo rather than
|
||||
* omitting the field, so an elvis on [description] yields an empty string that
|
||||
* renders as a blank line. Normalized HERE rather than in the parser because
|
||||
* [com.vitorpamplona.quartz.nip47WalletConnect.Nip47Server] serializes this same
|
||||
* class back onto the wire — rewriting blank to null at parse time would change
|
||||
* what a wallet service echoes.
|
||||
*/
|
||||
fun displayDescription(): String? = description?.ifBlank { null }
|
||||
}
|
||||
|
||||
class TlvRecord(
|
||||
|
||||
+100
-2
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.RawJson
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
|
||||
class NwcTransactionMetadata(
|
||||
@@ -41,16 +43,26 @@ class NwcTransactionMetadata(
|
||||
class NostrZapData(
|
||||
val pubkeyHex: String?,
|
||||
val recipientPubkeyHex: String?,
|
||||
val content: String?,
|
||||
)
|
||||
|
||||
fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) }
|
||||
|
||||
fun senderDisplayName(): String? = payerData?.name ?: payerData?.email
|
||||
fun senderDisplayName(): String? = payerData?.name?.ifBlank { null } ?: payerData?.email?.ifBlank { null }
|
||||
|
||||
fun recipientIdentifier(): String? = recipientData?.identifier
|
||||
fun recipientIdentifier(): String? = recipientData?.identifier?.ifBlank { null }
|
||||
|
||||
fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex
|
||||
|
||||
/**
|
||||
* The message to show for this transaction.
|
||||
*
|
||||
* A wallet that stores only `nostr` still carries the message: a zap request's
|
||||
* `content` IS the public zap comment. Private-zap messages are encrypted into
|
||||
* the `anon` tag rather than content, so nothing encrypted can surface here.
|
||||
*/
|
||||
fun displayComment(): String? = comment?.ifBlank { null } ?: nostr?.content?.ifBlank { null }
|
||||
|
||||
companion object {
|
||||
fun parse(metadata: Any?): NwcTransactionMetadata? {
|
||||
val map = metadata as? Map<*, *> ?: return null
|
||||
@@ -92,6 +104,7 @@ class NwcTransactionMetadata(
|
||||
NostrZapData(
|
||||
pubkeyHex = pubkeyHex,
|
||||
recipientPubkeyHex = recipientHex,
|
||||
content = n["content"] as? String,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,5 +119,90 @@ class NwcTransactionMetadata(
|
||||
nostr = nostr,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC-06: "The metadata MUST be no more than 4096 characters, otherwise MUST
|
||||
* be dropped." A wallet is required to discard an over-long object wholesale,
|
||||
* so breaching this loses the recipient entirely rather than degrading.
|
||||
*/
|
||||
const val MAX_METADATA_CHARS = 4096
|
||||
|
||||
// The keys and punctuation around the values:
|
||||
// `{"recipient_data":{"identifier":""},"comment":"","nostr":}` is 58 chars.
|
||||
// Escaping is counted separately by [escapedLength], so this is a fixed cost.
|
||||
private const val KEY_OVERHEAD = 64
|
||||
|
||||
/**
|
||||
* The length a string occupies once JSON-escaped.
|
||||
*
|
||||
* `comment` is free text a user typed, so its raw length is not what reaches
|
||||
* the wire: a quote or backslash becomes two characters and a control
|
||||
* character becomes six. Counting the raw length instead would let an
|
||||
* escaping-heavy comment breach [MAX_METADATA_CHARS] unnoticed — and NWC-06
|
||||
* makes the wallet drop the WHOLE object then, losing `recipient_data` too,
|
||||
* which is the degradation this budget exists to protect.
|
||||
*
|
||||
* Over-counts the short forms (`\n` is two characters, not six), which is
|
||||
* the safe direction.
|
||||
*/
|
||||
private fun escapedLength(value: String): Int =
|
||||
value.sumOf { char ->
|
||||
when {
|
||||
char == '"' || char == '\\' -> 2
|
||||
char < ' ' -> 6
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles NWC-06 `metadata` for an outgoing payment, or null when there is
|
||||
* nothing worth saying.
|
||||
*
|
||||
* `nostr` carries the zap request's OWN serialization verbatim, as [RawJson].
|
||||
*
|
||||
* NIP-57 sets a zap invoice's `description_hash` to the sha256 of the raw
|
||||
* JSON the LNURL callback received in `nostr=`, and that is
|
||||
* `LnZapRequestEvent.toJson()` — the exact string used here. A wallet can
|
||||
* therefore bind this stored event to the invoice it labels, which is what
|
||||
* turns "the client says it paid X" into something the wallet checked.
|
||||
*
|
||||
* Rebuilding the object from typed fields would put that binding at the mercy
|
||||
* of key order, escaping and number formatting matching by coincidence, and
|
||||
* it fails as a silently unlabelled row rather than as an error. Passing the
|
||||
* bytes through also sidesteps the number-widening hazard in
|
||||
* [com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyValue],
|
||||
* which resolves untyped numbers with `toDoubleOrNull()` BEFORE
|
||||
* `toLongOrNull()`: nothing here decomposes the event at all.
|
||||
*
|
||||
* When the whole object would breach [MAX_METADATA_CHARS], `nostr` is dropped
|
||||
* and the much smaller `recipient_data`/`comment` pair survives, so the row
|
||||
* still names the payee instead of arriving blank.
|
||||
*/
|
||||
fun build(
|
||||
zapRequest: Event?,
|
||||
recipientIdentifier: String?,
|
||||
comment: String?,
|
||||
): Map<String, Any?>? {
|
||||
val lean = mutableMapOf<String, Any?>()
|
||||
|
||||
recipientIdentifier?.ifBlank { null }?.let {
|
||||
lean["recipient_data"] = mapOf("identifier" to it)
|
||||
}
|
||||
comment?.ifBlank { null }?.let { lean["comment"] = it }
|
||||
|
||||
if (zapRequest != null) {
|
||||
// The serialized length of the `nostr` member, exactly — it is the
|
||||
// string that gets embedded, not a reconstruction of it.
|
||||
val raw = zapRequest.toJson()
|
||||
val chars =
|
||||
escapedLength(recipientIdentifier.orEmpty()) + escapedLength(comment.orEmpty()) +
|
||||
raw.length + KEY_OVERHEAD
|
||||
if (chars <= MAX_METADATA_CHARS) {
|
||||
lean["nostr"] = RawJson(raw)
|
||||
}
|
||||
}
|
||||
|
||||
return lean.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-11
@@ -22,23 +22,55 @@ package com.vitorpamplona.quartz.nip47WalletConnect.rpc
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
|
||||
|
||||
/**
|
||||
* A request parameter block carrying NIP-47's optional `metadata`, whose keys
|
||||
* NWC-06 defines.
|
||||
*
|
||||
* The field is `var` so a client can strip it before sending: it may only go to a
|
||||
* wallet that advertised `06` in its info event's `extensions` tag, because a
|
||||
* wallet that types `metadata` narrowly accepts a null but cannot decode an object
|
||||
* and answers a params error instead of paying.
|
||||
*/
|
||||
interface MetadataCarrying {
|
||||
var metadata: Map<String, Any?>?
|
||||
}
|
||||
|
||||
// REQUEST OBJECTS
|
||||
abstract class Request(
|
||||
sealed class Request(
|
||||
var method: String? = null,
|
||||
) : OptimizedSerializable
|
||||
) : OptimizedSerializable {
|
||||
/**
|
||||
* This request's NWC-06 metadata carrier, or null for a method that has none.
|
||||
*
|
||||
* Declared HERE, and overridden beside each method that carries one, so adding a
|
||||
* metadata-bearing method is a decision made where the method is written. The
|
||||
* alternative — a `when` over request types in the client — needs an `else`, and
|
||||
* an `else` silently leaks the field to a wallet that never opted in.
|
||||
*
|
||||
* create_connection is deliberately absent: its `metadata` names the connection
|
||||
* and is not NWC-06's per-payment blob.
|
||||
*/
|
||||
open val metadataCarrier: MetadataCarrying? get() = null
|
||||
}
|
||||
|
||||
// pay_invoice
|
||||
class PayInvoiceParams(
|
||||
var invoice: String? = null,
|
||||
var amount: Long? = null,
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
)
|
||||
override var metadata: Map<String, Any?>? = null,
|
||||
) : MetadataCarrying
|
||||
|
||||
class PayInvoiceMethod(
|
||||
var params: PayInvoiceParams? = null,
|
||||
) : Request(NwcMethod.PAY_INVOICE) {
|
||||
override val metadataCarrier get() = params
|
||||
|
||||
companion object {
|
||||
fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11))
|
||||
// `metadata` may only travel to a wallet that advertised it — see [MetadataCarrying].
|
||||
fun create(
|
||||
bolt11: String,
|
||||
metadata: Map<String, Any?>? = null,
|
||||
): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11, metadata = metadata))
|
||||
|
||||
fun create(
|
||||
bolt11: String,
|
||||
@@ -56,12 +88,14 @@ class PayParams(
|
||||
var payment: String? = null,
|
||||
var amount: Long? = null,
|
||||
var payer_note: String? = null,
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
)
|
||||
override var metadata: Map<String, Any?>? = null,
|
||||
) : MetadataCarrying
|
||||
|
||||
class PayMethod(
|
||||
var params: PayParams? = null,
|
||||
) : Request(NwcMethod.PAY) {
|
||||
override val metadataCarrier get() = params
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
payment: String,
|
||||
@@ -75,12 +109,14 @@ class PayMethod(
|
||||
class ReceiveParams(
|
||||
var amount: Long? = null,
|
||||
var description: String? = null,
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
)
|
||||
override var metadata: Map<String, Any?>? = null,
|
||||
) : MetadataCarrying
|
||||
|
||||
class ReceiveMethod(
|
||||
var params: ReceiveParams? = null,
|
||||
) : Request(NwcMethod.RECEIVE) {
|
||||
override val metadataCarrier get() = params
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
amount: Long? = null,
|
||||
@@ -116,12 +152,14 @@ class MakeInvoiceParams(
|
||||
var description: String? = null,
|
||||
var description_hash: String? = null,
|
||||
var expiry: Long? = null,
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
)
|
||||
override var metadata: Map<String, Any?>? = null,
|
||||
) : MetadataCarrying
|
||||
|
||||
class MakeInvoiceMethod(
|
||||
var params: MakeInvoiceParams? = null,
|
||||
) : Request(NwcMethod.MAKE_INVOICE) {
|
||||
override val metadataCarrier get() = params
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
amount: Long,
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.nip47WalletConnect.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* NIP-47's `extensions` tag on the kind 13194 info event: the optional NWC
|
||||
* extension specs a wallet service supports, space-separated (eg. `02 03 04`).
|
||||
*
|
||||
* This is how a client learns it may use anything beyond the core command set
|
||||
* without guessing. It matters most for request fields a wallet might not
|
||||
* understand — sending one to a wallet that never advertised support risks a
|
||||
* refusal on a method that would otherwise have worked.
|
||||
*/
|
||||
class ExtensionsTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "extensions"
|
||||
|
||||
// The specs this client knows how to use, so a caller names a constant
|
||||
// rather than a bare string at each gate.
|
||||
const val TRANSACTION_HISTORY = "05"
|
||||
const val METADATA_CONVENTIONS = "06"
|
||||
|
||||
fun parse(tag: Array<String>): List<String>? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag.drop(1)
|
||||
}
|
||||
|
||||
fun assemble(extensions: List<String>) = arrayOf(TAG_NAME, *extensions.toTypedArray())
|
||||
}
|
||||
}
|
||||
+34
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
|
||||
import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -83,6 +84,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
@@ -346,6 +348,38 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
}
|
||||
|
||||
// kinds 30392-30395 -- a Trusted List's title is the ONLY
|
||||
// human-authored text the family carries (`content` is a machine
|
||||
// echo of the membership, the member tags are hex ids, and
|
||||
// `metric`/`d` name the computation), so `indexableContent()` is
|
||||
// exactly `title()`. Without this branch the fallback below put
|
||||
// that title in the TERTIARY tier: a list titled "Verified Human"
|
||||
// matched the words on the same rung as a bio that happens to
|
||||
// mention them, and lost the prefix/typo columns a title gets.
|
||||
is TrustedListEvent -> {
|
||||
tiers(event, event.title(), null, null)
|
||||
}
|
||||
|
||||
// kind 30382 -- a contact card's petname is a trust provider's
|
||||
// NAME for a person, the direct analogue of kind 0's `name`, and
|
||||
// its summary is the description beside it. The encrypted half of
|
||||
// the card stays out, as it always has: petName()/summary() read
|
||||
// the public tag array only, and build() puts both in the NIP-44
|
||||
// content, so a card authored by this library has NO public text
|
||||
// beyond its topics.
|
||||
//
|
||||
// topics() is deliberately absent: it is `TopicTag`, which is the
|
||||
// `t` tag under another name (same predicate, same array), so the
|
||||
// tiers() funnel already carries every topic in the hashtag role.
|
||||
// Passing them again would index the same words twice -- which is
|
||||
// what the fallback below was doing, since indexableContent()
|
||||
// concatenates the topics INTO the body while the funnel added
|
||||
// them as hashtags. Whether that role is tokenized or kept as
|
||||
// keywords is the backend's call, per IndexableFields.
|
||||
is ContactCardEvent -> {
|
||||
tiers(event, event.petName(), event.summary(), null)
|
||||
}
|
||||
|
||||
is FeedDefinitionEvent -> {
|
||||
tiers(event, event.title(), null, null)
|
||||
}
|
||||
|
||||
+7
-3
@@ -34,9 +34,13 @@ import kotlin.test.assertNull
|
||||
/**
|
||||
* Exercises the **kotlinx** (native/iOS) NWC serializers directly — not through
|
||||
* `OptimizedJsonMapper`, whose JVM actual is Jackson — to cover the cross-backend
|
||||
* asymmetry: Jackson (JVM/Android) writes explicit `null` for every null field, and a
|
||||
* native peer parsing that output must read those as real nulls, not the string "null",
|
||||
* and must not crash on a null `metadata` object.
|
||||
* asymmetry on the DECODE side: a peer may write an explicit `null` for a field it
|
||||
* has nothing to say about, and a native client parsing that must read it as a real
|
||||
* null, not the string "null", and must not crash on a null `metadata` object.
|
||||
*
|
||||
* Our own Jackson backend no longer emits those on request params — see
|
||||
* [com.vitorpamplona.quartz.nip01Core.jackson.OmitNullsMixin] — but a third-party
|
||||
* wallet still may, so tolerating them on the way in remains required.
|
||||
*/
|
||||
class Nip47KotlinSerializationNullTest {
|
||||
@Test
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.nip47WalletConnect
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* NIP-47 marks request parameters optional, and a wallet may type one strictly.
|
||||
* Sending an absent parameter as an explicit `null` earned
|
||||
* `Invalid list_transactions params: from must be an integer` from a real wallet
|
||||
* and failed the request outright.
|
||||
*
|
||||
* EVERY params-bearing method is listed here on purpose. The Jackson mixin
|
||||
* registrations that fix this are a hand-maintained list, and unlike the two
|
||||
* `when` blocks over the sealed `Request` they are not compiler-checked — so a
|
||||
* thirteenth method can be added, serialize correctly on native, and regress on
|
||||
* JVM/Android alone. This list is the only thing that would catch that.
|
||||
*/
|
||||
class Nip47NullParamOmissionTest {
|
||||
private val requests: List<Pair<String, Request>> =
|
||||
listOf(
|
||||
// The reported failure: five of eight fields absent.
|
||||
"list_transactions" to ListTransactionsMethod.create(limit = 20, offset = 0, unpaid = false),
|
||||
"pay_invoice" to PayInvoiceMethod.create("lnbc50n1abc"),
|
||||
"pay" to PayMethod.create("bitcoin:?lno=lno1abc"),
|
||||
"receive" to ReceiveMethod.create(amount = 21000L),
|
||||
"pay_keysend" to PayKeysendMethod.create(amount = 21000L, pubkey = "0266e4"),
|
||||
// Reaches the NESTED TlvRecord, with one of its two optional fields absent.
|
||||
// A record is only checked when the list is non-empty, so the plain
|
||||
// pay_keysend fixture above never executes this path.
|
||||
"pay_keysend+tlv" to
|
||||
PayKeysendMethod.create(
|
||||
amount = 21000L,
|
||||
pubkey = "0266e4",
|
||||
tlvRecords = listOf(TlvRecord(type = 5482373484L)),
|
||||
),
|
||||
"make_invoice" to MakeInvoiceMethod.create(amount = 21000L),
|
||||
"lookup_invoice" to LookupInvoiceMethod.createByHash("31afdf1"),
|
||||
"make_hold_invoice" to MakeHoldInvoiceMethod.create(amount = 21000L, paymentHash = "31afdf1"),
|
||||
"cancel_hold_invoice" to CancelHoldInvoiceMethod.create("31afdf1"),
|
||||
"settle_hold_invoice" to SettleHoldInvoiceMethod.create("0123456789abcdef"),
|
||||
"sign_message" to SignMessageMethod.create("hello"),
|
||||
"create_connection" to CreateConnectionMethod.create(pubkey = "abc123", name = "app"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun noRequestEverSendsANullParam() {
|
||||
requests.forEach { (name, request) ->
|
||||
val json = OptimizedJsonMapper.toJson(request)
|
||||
val params = Json.parseToJsonElement(json).jsonObject["params"] as? JsonObject
|
||||
|
||||
// RECURSIVE: `tlv_records` holds objects with optional fields of their own,
|
||||
// so a null can hide a level below the params object.
|
||||
params?.let { assertNoNulls(it, name, json) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertNoNulls(
|
||||
element: JsonElement,
|
||||
name: String,
|
||||
json: String,
|
||||
) {
|
||||
when (element) {
|
||||
is JsonObject ->
|
||||
element.forEach { (key, value) ->
|
||||
assertTrue(value !is JsonNull, "$name sent \"$key\": null - omit it instead. Full: $json")
|
||||
assertNoNulls(value, name, json)
|
||||
}
|
||||
|
||||
is JsonArray -> element.forEach { assertNoNulls(it, name, json) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The document the failing wallet rejected, now minimal. Asserted as a KEY SET
|
||||
* rather than a literal string: which keys travel is the property under test,
|
||||
* while their order is each backend's own business.
|
||||
*/
|
||||
@Test
|
||||
fun listTransactionsCarriesOnlyWhatWasAsked() {
|
||||
val json = OptimizedJsonMapper.toJson(ListTransactionsMethod.create(limit = 20, offset = 0, unpaid = false))
|
||||
val params = assertNotNull(Json.parseToJsonElement(json).jsonObject["params"], "no params in $json").jsonObject
|
||||
|
||||
assertEquals(setOf("limit", "offset", "unpaid"), params.keys, "unexpected keys in $json")
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant the bug broke: one wire format, two backends, same document.
|
||||
* This is the actual fix — the mixin is only the mechanism that restores it.
|
||||
*/
|
||||
@Test
|
||||
fun bothBackendsProduceTheSameDocument() {
|
||||
requests.forEach { (name, request) ->
|
||||
val viaJackson = Json.parseToJsonElement(OptimizedJsonMapper.toJson(request)).jsonObject
|
||||
val viaKotlinx = Json.parseToJsonElement(Json.encodeToString(Nip47RequestKSerializer, request)).jsonObject
|
||||
|
||||
assertEquals(viaKotlinx, viaJackson, "$name differs between backends")
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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.nip47WalletConnect
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* NWC-06 metadata on OUTGOING payments: what we send, when we are allowed to send
|
||||
* it, and what a row makes of it coming back.
|
||||
*/
|
||||
class NwcOutgoingMetadataTest {
|
||||
private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
|
||||
private val payerHex = "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f"
|
||||
|
||||
private fun zapRequest(
|
||||
content: String = "great post",
|
||||
relays: List<String> = listOf("wss://relay.damus.io"),
|
||||
) = Event(
|
||||
id = "a".repeat(64),
|
||||
pubKey = payerHex,
|
||||
createdAt = 1756000000L,
|
||||
kind = 9734,
|
||||
tags = arrayOf(arrayOf("p", recipientHex), arrayOf("relays", *relays.toTypedArray())),
|
||||
content = content,
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
|
||||
// --- build ---
|
||||
|
||||
@Test
|
||||
fun nothingToSayProducesNoMetadataAtAll() {
|
||||
assertNull(NwcTransactionMetadata.build(null, null, null))
|
||||
assertNull(NwcTransactionMetadata.build(null, " ", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leanPairIsSentWithoutAZapRequest() {
|
||||
val meta = assertNotNull(NwcTransactionMetadata.build(null, "user@domain.com", "thanks"))
|
||||
assertEquals(mapOf("identifier" to "user@domain.com"), meta["recipient_data"])
|
||||
assertEquals("thanks", meta["comment"])
|
||||
assertFalse(meta.containsKey("nostr"))
|
||||
}
|
||||
|
||||
/**
|
||||
* THE INTEROP PROPERTY. NIP-57 sets a zap invoice's `description_hash` to the
|
||||
* sha256 of the raw JSON the LNURL callback received in `nostr=` — which is
|
||||
* `LnZapRequestEvent.toJson()` (see LightningAddressResolver). A wallet that
|
||||
* binds a stored zap request to the invoice it labels hashes the bytes of the
|
||||
* `nostr` member, so anything short of byte-identity reads as a forged event
|
||||
* and the row is silently stored unlabelled.
|
||||
*/
|
||||
@Test
|
||||
fun theNostrMemberIsByteIdenticalToWhatTheLnurlCallbackReceived() {
|
||||
val event = zapRequest(content = "quoted \" and & <angled> \u00fcn\u00efcode \ud83d\ude00")
|
||||
val callbackBytes = event.toJson()
|
||||
|
||||
val wire =
|
||||
OptimizedJsonMapper.toJson(
|
||||
PayInvoiceMethod.create("lnbc50n1abc", NwcTransactionMetadata.build(event, "user@domain.com", "hi")),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
wire.contains("\"nostr\":" + callbackBytes),
|
||||
"metadata.nostr must be the callback's own bytes.\n sent: $callbackBytes\n wire: $wire",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theZapRequestSurvivesAsAReadableObject() {
|
||||
val event = zapRequest()
|
||||
val wire = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(event, null, null)))
|
||||
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
|
||||
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
|
||||
|
||||
// Raw on the way out, a normal object on the way back in.
|
||||
assertEquals(recipientHex, parsed.recipientPubkeyHex())
|
||||
assertEquals(payerHex, parsed.senderPubkeyHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOversizeZapRequestIsDroppedButTheRowStillNamesThePayee() {
|
||||
// NWC-06: over 4096 characters a wallet MUST drop the WHOLE object, so
|
||||
// breaching it would lose the payee entirely rather than degrade.
|
||||
val many = List(200) { "wss://relay-with-a-fairly-long-hostname-.example.com" }
|
||||
val meta = assertNotNull(NwcTransactionMetadata.build(zapRequest(relays = many), "user@domain.com", "hi"))
|
||||
|
||||
assertFalse(meta.containsKey("nostr"))
|
||||
assertEquals(mapOf("identifier" to "user@domain.com"), meta["recipient_data"])
|
||||
assertEquals("hi", meta["comment"])
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that does not advertise `allowsNostr` never receives the zap request,
|
||||
* so its invoice commits to nothing about it. Passing null here is how the caller
|
||||
* says so, and the metadata must then make no claim it cannot support — while the
|
||||
* payee's address still labels the row.
|
||||
*/
|
||||
@Test
|
||||
fun withoutAZapRequestTheRowIsStillNamedButClaimsNoBinding() {
|
||||
val meta = assertNotNull(NwcTransactionMetadata.build(null, "user@domain.com", "great post"))
|
||||
|
||||
assertFalse(meta.containsKey("nostr"))
|
||||
assertEquals(mapOf("identifier" to "user@domain.com"), meta["recipient_data"])
|
||||
assertEquals("great post", meta["comment"])
|
||||
}
|
||||
|
||||
/**
|
||||
* `comment` is free text, and JSON escaping expands it on the way out. Counting the
|
||||
* raw length would let an escaping-heavy comment breach the 4096 ceiling unnoticed
|
||||
* — and NWC-06 makes the wallet drop the WHOLE object then, taking `recipient_data`
|
||||
* with it.
|
||||
*/
|
||||
@Test
|
||||
fun theCeilingCountsEscapedLengthNotRawLength() {
|
||||
// Every character escapes to six, so 900 raw chars occupy ~5400 on the wire.
|
||||
val controlHeavy = "\u0001".repeat(900)
|
||||
val meta = assertNotNull(NwcTransactionMetadata.build(zapRequest(), "user@domain.com", controlHeavy))
|
||||
|
||||
assertFalse(meta.containsKey("nostr"), "the escaped comment alone exceeds the ceiling")
|
||||
|
||||
// The same length in plain characters leaves room for the zap request.
|
||||
val plain = "a".repeat(900)
|
||||
assertTrue(assertNotNull(NwcTransactionMetadata.build(zapRequest(), "user@domain.com", plain)).containsKey("nostr"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whatWeSendStaysUnderTheSpecCeiling() {
|
||||
val meta = NwcTransactionMetadata.build(zapRequest(), "user@domain.com", "great post")
|
||||
val serialized = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc", meta))
|
||||
assertTrue(serialized.length < NwcTransactionMetadata.MAX_METADATA_CHARS)
|
||||
}
|
||||
|
||||
// --- the wire ---
|
||||
|
||||
@Test
|
||||
fun metadataSurvivesASerializationRoundTrip() {
|
||||
val meta = NwcTransactionMetadata.build(zapRequest(), "user@domain.com", "great post")
|
||||
val json = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc", meta))
|
||||
|
||||
assertTrue(json.contains("\"kind\":9734"), "kind must stay an integer: $json")
|
||||
assertTrue(json.contains("\"created_at\":1756000000"), "created_at must stay an integer: $json")
|
||||
|
||||
val back = OptimizedJsonMapper.fromJsonTo<Request>(json)
|
||||
assertIs<PayInvoiceMethod>(back)
|
||||
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
|
||||
assertEquals(recipientHex, parsed.recipientPubkeyHex())
|
||||
assertEquals("great post", parsed.displayComment())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMetadataMeansTheRequestIsUnchanged() {
|
||||
// The regression test that protects every wallet which has not advertised
|
||||
// NWC-06: what they receive must be byte-identical to what they receive today.
|
||||
val expected = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc"))
|
||||
val actual = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc50n1abc", null))
|
||||
assertEquals(expected, actual)
|
||||
assertFalse(actual.contains("recipient_data"))
|
||||
}
|
||||
|
||||
// --- the gate ---
|
||||
|
||||
@Test
|
||||
fun extensionsTagIsReadFromTheInfoEvent() {
|
||||
assertEquals(listOf("02", "05", "06"), infoWith(arrayOf("extensions", "02 05 06")).extensions())
|
||||
assertTrue(infoWith(arrayOf("extensions", "05 06")).supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWalletThatSaysNothingReadsAsNo() {
|
||||
assertFalse(infoWith().supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
|
||||
assertFalse(infoWith(arrayOf("extensions", "")).supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
|
||||
assertFalse(infoWith(arrayOf("extensions", "02 03")).supportsExtension(ExtensionsTag.METADATA_CONVENTIONS))
|
||||
assertTrue(infoWith().extensions().isEmpty())
|
||||
}
|
||||
|
||||
private fun infoWith(vararg tags: Array<String>) =
|
||||
NwcInfoEvent(
|
||||
id = "c".repeat(64),
|
||||
pubKey = payerHex,
|
||||
createdAt = 1756000000L,
|
||||
tags = arrayOf(*tags),
|
||||
content = "pay_invoice get_balance",
|
||||
sig = "d".repeat(128),
|
||||
)
|
||||
|
||||
// --- reading it back ---
|
||||
|
||||
@Test
|
||||
fun anOutgoingRowResolvesThePayeeFromThePTag() {
|
||||
val wire =
|
||||
OptimizedJsonMapper.toJson(
|
||||
PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(zapRequest(content = "for the article"), "user@domain.com", "")),
|
||||
)
|
||||
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
|
||||
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
|
||||
|
||||
// The p tag, not the pubkey: on an outgoing zap the pubkey is US.
|
||||
assertEquals(recipientHex, parsed.recipientPubkeyHex())
|
||||
assertEquals("user@domain.com", parsed.recipientIdentifier())
|
||||
// A wallet storing only `nostr` still yields the message.
|
||||
assertEquals("for the article", parsed.displayComment())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankFieldsReadAsAbsent() {
|
||||
val parsed =
|
||||
assertNotNull(
|
||||
NwcTransactionMetadata.parse(
|
||||
mapOf(
|
||||
"comment" to " ",
|
||||
"recipient_data" to mapOf("identifier" to ""),
|
||||
"nostr" to mapOf("content" to ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNull(parsed.recipientIdentifier())
|
||||
assertNull(parsed.displayComment())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDescriptionIsNotAName() {
|
||||
// The wallet-side habit this exists for: an empty `description` string
|
||||
// rather than an omitted field. The row must fall back, not render an empty line.
|
||||
val tx = NwcTransaction(type = "outgoing", description = "", amount = 21000L)
|
||||
assertEquals("", tx.description, "the raw field keeps what the wallet sent")
|
||||
assertNull(tx.displayDescription(), "but nothing downstream sees an empty name")
|
||||
}
|
||||
}
|
||||
+3
@@ -285,6 +285,9 @@ class RequestTest {
|
||||
assertEquals(1000L, request.params?.from)
|
||||
assertEquals(2000L, request.params?.until)
|
||||
assertEquals(10, request.params?.limit)
|
||||
// Omitted is how an absent optional param arrives, and it must read as null
|
||||
// rather than as a default — this is the shape we now send.
|
||||
assertNull(request.params?.offset)
|
||||
}
|
||||
|
||||
// --- GetBalance ---
|
||||
|
||||
+72
@@ -21,12 +21,14 @@
|
||||
package com.vitorpamplona.quartz.nip50Search
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import kotlin.test.Test
|
||||
@@ -110,6 +112,76 @@ class SearchFieldExtractorTest {
|
||||
assertEquals(IndexableFields.Profile(name = "CoolApp", about = "an app", website = "https://coolapp.example"), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedListsDecomposeIntoTheirTitle() {
|
||||
// The title is the whole of indexableContent() for the family, and it
|
||||
// is a title: primary, not the body tier. Everything else the list
|
||||
// carries -- content echo, member tags, metric, d -- stays out.
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "tl-pin-verified-human"),
|
||||
arrayOf("title", "Verified Human"),
|
||||
arrayOf("metric", "pinned-tag-membership"),
|
||||
arrayOf("p", alice, "", "87"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(UserTrustedListEvent("d".repeat(64), alice, 1L, tags, """{"members":[]}""", ""))
|
||||
assertEquals(IndexableFields.Tiered(primary = listOf("Verified Human")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titlelessTrustedListsExtractNothing() {
|
||||
// Most machine-published lists have no title. The branch reads title()
|
||||
// directly rather than indexableContent(), so the None comes from the
|
||||
// tiers funnel finding nothing to clean -- not from title() ?: "".
|
||||
val tags = arrayOf(arrayOf("d", "tl-pin-untitled"), arrayOf("p", alice, "", "50"))
|
||||
assertEquals(IndexableFields.None, SearchFieldExtractor.extract(UserTrustedListEvent("e".repeat(64), alice, 1L, tags, "", "")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactCardsDecomposeIntoPetnameSummaryAndTopics() {
|
||||
// A provider's petname for a person is that provider's NAME for them,
|
||||
// so it lands where kind 0's name does. topics() reads `t` tags, so
|
||||
// the tiers() funnel carries them once, as hashtags.
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("d", alice),
|
||||
arrayOf("petname", "Verified Human"),
|
||||
arrayOf("summary", "vouched by two independent raters"),
|
||||
arrayOf("t", "bitcoin"),
|
||||
arrayOf("rank", "87"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(ContactCardEvent("f".repeat(64), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Verified Human"),
|
||||
secondary = listOf("vouched by two independent raters"),
|
||||
hashtags = listOf("bitcoin"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactCardsCarryTopicsEvenWithNoPublicPetname() {
|
||||
// THE SHAPE THIS LIBRARY ITSELF PUBLISHES: build() puts petname and
|
||||
// summary in the NIP-44 content, so a card's only public text is its
|
||||
// topics. They must still reach the backend -- through the hashtag
|
||||
// role, once -- and a hashtags-only extraction must not normalize to
|
||||
// None (Tiered.isEmpty() compares against a fully-empty Tiered).
|
||||
val tags = arrayOf(arrayOf("d", alice), arrayOf("t", "bitcoin"), arrayOf("t", "nostr"), arrayOf("rank", "87"))
|
||||
val fields = SearchFieldExtractor.extract(ContactCardEvent("2a".repeat(32), alice, 1L, tags, "encrypted", ""))
|
||||
assertEquals(IndexableFields.Tiered(hashtags = listOf("bitcoin", "nostr")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactCardsWithNoPublicTextExtractNothing() {
|
||||
// The petname and summary of a private card live in the NIP-44
|
||||
// encrypted content, which is never indexed -- so a card carrying only
|
||||
// scores has nothing to search.
|
||||
val tags = arrayOf(arrayOf("d", alice), arrayOf("rank", "87"), arrayOf("followers", "1200"))
|
||||
assertEquals(IndexableFields.None, SearchFieldExtractor.extract(ContactCardEvent("1a".repeat(32), alice, 1L, tags, "encrypted", "")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonSearchableKindsExtractNothing() {
|
||||
// Kind 7 reactions are not SearchableEvent.
|
||||
|
||||
+33
@@ -31,6 +31,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.RawJson
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MessageDeserializer
|
||||
@@ -58,9 +59,22 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
|
||||
@@ -84,6 +98,7 @@ class JacksonMapper {
|
||||
.registerModule(
|
||||
SimpleModule()
|
||||
// nip 01
|
||||
.addSerializer(RawJson::class.java, RawJsonSerializer())
|
||||
.addSerializer(Event::class.java, EventSerializer())
|
||||
.addDeserializer(Event::class.java, EventDeserializer())
|
||||
.addSerializer(Filter::class.java, FilterSerializer())
|
||||
@@ -106,6 +121,24 @@ class JacksonMapper {
|
||||
.addDeserializer(Request::class.java, RequestDeserializer())
|
||||
.addSerializer(Notification::class.java, NotificationSerializer())
|
||||
.addDeserializer(Notification::class.java, NotificationDeserializer())
|
||||
// NIP-47's optional params are OMITTED when null — see OmitNullsMixin.
|
||||
// Matches what the kotlinx backend has always done.
|
||||
.setMixInAnnotation(PayInvoiceParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(PayParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(ReceiveParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(PayKeysendParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(MakeInvoiceParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(LookupInvoiceParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(ListTransactionsParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(MakeHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(CancelHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(SettleHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(SignMessageParams::class.java, OmitNullsMixin::class.java)
|
||||
.setMixInAnnotation(CreateConnectionParams::class.java, OmitNullsMixin::class.java)
|
||||
// NESTED, and the only params field that is not a primitive or an
|
||||
// already-registered type: a TlvRecord inside pay_keysend's
|
||||
// `tlv_records` has two independently optional fields of its own.
|
||||
.setMixInAnnotation(TlvRecord::class.java, OmitNullsMixin::class.java)
|
||||
// nip 46
|
||||
.addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer())
|
||||
.addSerializer(BunkerRequest::class.java, BunkerRequestSerializer())
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.jackson
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
|
||||
/**
|
||||
* Applied to a reflectively-serialized DTO so Jackson OMITS a null field instead
|
||||
* of writing it.
|
||||
*
|
||||
* Optional protocol fields are absent, not null. A peer is free to type one
|
||||
* strictly: sending `"from": null` for an absent `from` earned
|
||||
* `Invalid list_transactions params: from must be an integer` from a NIP-47
|
||||
* wallet, and the request failed.
|
||||
*
|
||||
* A MIXIN rather than an annotation on the class, because these DTOs live in
|
||||
* `commonMain` and Jackson annotations are JVM-only. Class-level rather than the
|
||||
* mapper-wide `setSerializationInclusion`, which in Jackson 2.x also suppresses
|
||||
* null MAP ENTRIES — and [com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement]
|
||||
* deliberately keeps those, so a global setting would close one backend
|
||||
* divergence by opening another.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
abstract class OmitNullsMixin
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.jackson
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.RawJson
|
||||
|
||||
/** Writes [RawJson.json] straight into the output, unquoted and unescaped. */
|
||||
class RawJsonSerializer : StdSerializer<RawJson>(RawJson::class.java) {
|
||||
override fun serialize(
|
||||
value: RawJson,
|
||||
gen: JsonGenerator,
|
||||
provider: SerializerProvider,
|
||||
) {
|
||||
gen.writeRawValue(value.json)
|
||||
}
|
||||
}
|
||||
+11
@@ -25,6 +25,9 @@ import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
|
||||
@@ -119,6 +122,14 @@ class RequestSerializer : StdSerializer<Request>(Request::class.java) {
|
||||
gen.writeObjectField("params", value.params)
|
||||
}
|
||||
}
|
||||
|
||||
// Parameterless: `method` alone is the whole request. Spelled out rather
|
||||
// than left to fall through, because Request is sealed and the compiler
|
||||
// now makes every method state which of the two shapes it is.
|
||||
is GetBalanceMethod,
|
||||
is GetBudgetMethod,
|
||||
is GetInfoMethod,
|
||||
-> Unit
|
||||
}
|
||||
gen.writeEndObject()
|
||||
}
|
||||
|
||||
@@ -73,12 +73,11 @@ node shim-events.mjs /path/to/other/shim.js # diff a candidate against it
|
||||
Set `CHROMIUM_PATH` if your Chromium lives somewhere other than
|
||||
`/opt/pw-browsers/chromium-1194/chrome-linux/chrome`.
|
||||
|
||||
**Why this and not a JVM unit test.** The host-side parser
|
||||
(`parseImeEvent`) runs on Android's `org.json`, which the unit tests stub out
|
||||
(`unitTests.isReturnDefaultValues = true` in `amethyst/build.gradle.kts`, and
|
||||
there is no Robolectric); a Kotlin test would "pass" without parsing anything.
|
||||
The half worth protecting is the page↔host contract, and that only exists in a
|
||||
browser.
|
||||
**Why this and not a JVM unit test.** The half worth protecting is the
|
||||
page↔host contract — real browser focus/gesture behavior and the envelopes the
|
||||
shim emits for it — and that only exists in a browser. A JVM test of the
|
||||
host-side parser (`parseImeEvent`, kotlinx.serialization) would only re-parse
|
||||
envelopes the test itself fabricated.
|
||||
|
||||
## `perf.html` — why does the embed feel slower than the full-screen browser?
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//
|
||||
// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium
|
||||
// with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*`
|
||||
// envelopes it emits. This is the only honest automated coverage for this code: the host-side parser runs
|
||||
// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so
|
||||
// a Kotlin test of it would pass without parsing anything.
|
||||
// envelopes it emits. This is the only honest automated coverage for this code: the half worth protecting
|
||||
// is the page↔host contract (real browser focus/gesture behavior), which no JVM unit test of the host-side
|
||||
// parser (`parseImeEvent`, kotlinx.serialization) can exercise.
|
||||
//
|
||||
// cd tools/ime-test && npm i playwright-core && node shim-events.mjs
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user