Merge branch 'main' into nrobi144/desktop-phase1

This commit is contained in:
Vitor Pamplona
2026-01-07 09:23:57 -05:00
committed by GitHub
81 changed files with 4650 additions and 2445 deletions
@@ -200,7 +200,7 @@ class AppModules(
val relayStats = RelayStats(client)
// Logs debug messages when needed
val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null
val detailedLogger = if (isDebug) RelayLogger(client, false, false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null
@@ -248,14 +248,12 @@ object LocalPreferences {
withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
if (!File(prefsDir, it).delete()) {
if (it.contains(npub) && !File(prefsDir, it).delete()) {
Log.w("LocalPreferences", "Failed to delete preference file: $it")
}
}
}
}
}
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
checkNotInMainThread()
@@ -1563,6 +1563,15 @@ class Account(
}
}
suspend fun removeBookmark(note: Note) {
if (!isWriteable() || note.isDraft()) return
val event = bookmarkState.removeBookmark(note)
if (event != null) {
sendMyPublicAndPrivateOutbox(event)
}
}
suspend fun createAuthEvent(
relay: NormalizedRelayUrl,
challenge: String,
@@ -95,7 +95,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats
.get(relay)
.newSpam("$link1 $link2")
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -123,7 +123,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats
.get(relay)
.newSpam("$link1 $link2")
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -1106,7 +1106,6 @@ object LocalCache : ILocalCache {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (new) {
println("AABBCC New ContactCard about ${event.aboutUser()}")
val about = checkGetOrCreateUser(event.aboutUser()) ?: return new
about.cards().addCard(note)
}
@@ -2890,7 +2889,7 @@ object LocalCache : ILocalCache {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("LocalCache", "Cannot consume ${event.kind}", e)
Log.w("LocalCache", "Cannot consume ${event.toJson()} from ${relay?.url}", e)
false
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@Stable
class BookmarkListState(
val signer: NostrSigner,
val cache: LocalCache,
@@ -274,4 +276,26 @@ class BookmarkListState(
null
}
}
suspend fun removeBookmark(note: Note): BookmarkListEvent? {
val bookmarkList = getBookmarkList()
return if (bookmarkList != null) {
if (note is AddressableNote) {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
signer = signer,
)
} else {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
signer = signer,
)
}
} else {
null
}
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.logging
import android.os.Build
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
@@ -59,8 +58,8 @@ class Logging {
}.penaltyLog()
.build(),
)
Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start()
// Looper.getMainLooper().setMessageLogging(LogMonitor())
// ChoreographerHelper.start()
// Enable recomposition tracking ONLY in debug builds
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
@@ -74,64 +74,23 @@ fun VoiceMessagePreview(
var progress by remember { mutableFloatStateOf(0f) }
var mediaPlayer by remember { mutableStateOf<MediaPlayer?>(null) }
// Initialize MediaPlayer
DisposableEffect(voiceMetadata.url, localFile) {
val player = createMediaPlayer(voiceMetadata.url, localFile)
player?.setOnCompletionListener {
ManageMediaPlayer(
voiceMetadata = voiceMetadata,
localFile = localFile,
onCompletion = {
isPlaying = false
progress = 0f
}
mediaPlayer = player
},
onPlayerChanged = { mediaPlayer = it },
onRelease = { isPlaying = false },
)
onDispose {
// Stop playback and clean up
try {
player?.stop()
} catch (e: IllegalStateException) {
// Player might already be stopped
Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e)
}
player?.release()
mediaPlayer = null
isPlaying = false
}
}
// Update progress while playing
LaunchedEffect(mediaPlayer, isPlaying) {
// Capture player reference to avoid reading volatile state repeatedly
val player = mediaPlayer
if (player != null && isPlaying) {
while (isActive) {
try {
if (player.isPlaying) {
val current = player.currentPosition.toFloat()
val duration = player.duration.toFloat()
// Validate values before calculating progress
val newProgress =
if (duration > 0 && current >= 0) {
(current / duration).coerceIn(0f, 1f)
} else {
0f
}
// Only update if value is valid (not NaN or Infinity)
if (newProgress.isFinite()) {
progress = newProgress
}
} else {
// Player stopped, exit loop and let LaunchedEffect restart
break
}
} catch (e: IllegalStateException) {
// Player in invalid state, stop tracking
Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e)
isPlaying = false
break
}
delay(100)
}
}
}
TrackPlaybackProgress(
mediaPlayer = mediaPlayer,
isPlaying = isPlaying,
onProgressUpdate = { progress = it },
onInvalidState = { isPlaying = false },
)
Box(
modifier =
@@ -150,27 +109,13 @@ fun VoiceMessagePreview(
// Play/Pause Button
IconButton(
onClick = {
val player = mediaPlayer
if (player != null) {
try {
if (isPlaying) {
player.pause()
isPlaying = false
} else {
// Validate progress before comparison
if (progress.isFinite() && progress >= 1f) {
player.seekTo(0)
progress = 0f
}
player.start()
isPlaying = true
}
} catch (e: IllegalStateException) {
// MediaPlayer in invalid state, ignore
Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e)
isPlaying = false
}
}
handlePlayPauseClick(
mediaPlayer = mediaPlayer,
isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
)
},
modifier = Modifier.size(48.dp),
) {
@@ -194,24 +139,11 @@ fun VoiceMessagePreview(
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
onProgressChange = { newProgress ->
// Validate incoming progress value
if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) {
val player = mediaPlayer
if (player != null) {
try {
val duration = player.duration
// Only seek if duration is valid
if (duration > 0) {
val newPosition = (newProgress * duration).toInt()
player.seekTo(newPosition)
progress = newProgress
}
} catch (e: IllegalStateException) {
// MediaPlayer in invalid state, ignore
Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e)
}
}
}
handleWaveformScrub(
newProgress = newProgress,
mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it },
)
},
)
@@ -240,6 +172,115 @@ fun VoiceMessagePreview(
}
}
@Composable
private fun ManageMediaPlayer(
voiceMetadata: AudioMeta,
localFile: File?,
onCompletion: () -> Unit,
onPlayerChanged: (MediaPlayer?) -> Unit,
onRelease: () -> Unit,
) {
DisposableEffect(voiceMetadata.url, localFile) {
val player = createMediaPlayer(voiceMetadata.url, localFile)
player?.setOnCompletionListener { onCompletion() }
onPlayerChanged(player)
onDispose {
try {
player?.stop()
} catch (e: IllegalStateException) {
Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e)
}
player?.release()
onPlayerChanged(null)
onRelease()
}
}
}
@Composable
private fun TrackPlaybackProgress(
mediaPlayer: MediaPlayer?,
isPlaying: Boolean,
onProgressUpdate: (Float) -> Unit,
onInvalidState: () -> Unit,
) {
LaunchedEffect(mediaPlayer, isPlaying) {
val player = mediaPlayer ?: return@LaunchedEffect
if (!isPlaying) return@LaunchedEffect
while (isActive) {
try {
if (!player.isPlaying) break
calculateProgress(player.currentPosition.toFloat(), player.duration.toFloat())?.let { onProgressUpdate(it) }
} catch (e: IllegalStateException) {
Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e)
onInvalidState()
break
}
delay(100)
}
}
}
private fun calculateProgress(
current: Float,
duration: Float,
): Float? {
val progress =
if (duration > 0 && current >= 0) {
(current / duration).coerceIn(0f, 1f)
} else {
0f
}
return progress.takeIf { it.isFinite() }
}
private fun handlePlayPauseClick(
mediaPlayer: MediaPlayer?,
isPlaying: Boolean,
progress: Float,
onProgressReset: () -> Unit,
onPlayingChanged: (Boolean) -> Unit,
) {
val player = mediaPlayer ?: return
try {
if (isPlaying) {
player.pause()
onPlayingChanged(false)
return
}
if (progress.isFinite() && progress >= 1f) {
player.seekTo(0)
onProgressReset()
}
player.start()
onPlayingChanged(true)
} catch (e: IllegalStateException) {
Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e)
onPlayingChanged(false)
}
}
private fun handleWaveformScrub(
newProgress: Float,
mediaPlayer: MediaPlayer?,
onProgressChanged: (Float) -> Unit,
) {
if (!newProgress.isFinite() || newProgress < 0f || newProgress > 1f) return
val player = mediaPlayer ?: return
try {
val duration = player.duration
if (duration > 0) {
val newPosition = (newProgress * duration).toInt()
player.seekTo(newPosition)
onProgressChanged(newProgress)
}
} catch (e: IllegalStateException) {
Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e)
}
}
private fun createMediaPlayer(
url: String,
localFile: File?,
@@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.myapplication.DisappearingBottomBar
import com.vitorpamplona.myapplication.DisappearingFloatingButton
import com.vitorpamplona.myapplication.DisappearingTopBar
import com.vitorpamplona.myapplication.enterAlwaysScrollBehavior
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.myapplication
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
@@ -58,7 +58,7 @@ fun DisappearingTopBar(
scrollBehavior: CustomEnterAlwaysScrollBehavior,
content: @Composable (ColumnScope.() -> Unit),
) {
ResetDisappearingOnResume(scrollBehavior)
// ResetDisappearingOnResume(scrollBehavior)
// Set up support for resizing the top app bar when vertically dragging the bar itself.
val appBarDragModifier =
@@ -58,12 +58,12 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
@@ -46,7 +46,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
@@ -458,14 +457,6 @@ fun ListContent(
NavigationRow(
title = R.string.bookmarks,
icon = Icons.Outlined.BookmarkBorder,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Bookmarks,
)
NavigationRow(
title = R.string.bookmark_lists,
icon = Icons.Outlined.CollectionsBookmark,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
@@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String ->
"https://njump.to/$nip19BechAddress"
}
val nipLink = { nipNumber: String ->
"https://nostrhub.io/$nipNumber"
}
val graspLink = { graspNumber: String ->
"https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/$graspNumber.md"
}
val externalLinkForNote = { note: Note ->
if (note is AddressableNote) {
if (note.event?.bountyBaseReward() != null) {
@@ -56,6 +56,12 @@ fun showAmountInteger(amount: BigDecimal?): String {
}
}
fun showAmountInteger(amount: Int?): String {
if (amount == null) return "0"
return showAmountIntegerWithZero(BigDecimal.valueOf(amount.toLong()))
}
fun showAmountIntegerWithZero(amount: BigDecimal?): String {
if (amount == null) return "0"
if (amount.abs() < BigDecimal(0.01)) return "0"
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
@@ -44,8 +44,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@@ -93,7 +93,7 @@ private fun RenderBookmarkScreen(
isInvertedLayout = false,
topBar = {
Column {
TopBarWithBackButton(stringRes(id = R.string.bookmarks), nav::popBack)
TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack)
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
@@ -42,7 +42,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -59,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -118,7 +118,7 @@ fun BookmarkGroupScreenView(
Scaffold(
topBar = {
Column {
TopAppBar(
ShorterTopAppBar(
title = {
TitleAndDescription(bookmarkGroupViewModel)
},
@@ -20,34 +20,43 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.flow.StateFlow
@Composable
fun ListOfBookmarkGroupsFeedView(
defaultBookmarks: BookmarkListState,
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
onOpenItem: (String, BookmarkType) -> Unit,
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -56,13 +65,15 @@ fun ListOfBookmarkGroupsFeedView(
) {
val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle()
if (bookmarkGroupFeedState.isEmpty()) {
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
} else {
LazyColumn(
state = rememberLazyListState(),
contentPadding = FeedPadding,
) {
item {
DefaultBookmarkList(defaultBookmarks, openDefaultBookmarks)
HorizontalDivider(thickness = DividerThickness)
}
itemsIndexed(
bookmarkGroupFeedState,
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
@@ -80,16 +91,47 @@ fun ListOfBookmarkGroupsFeedView(
}
}
}
}
@Composable
fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
Column(
Modifier.fillMaxSize().padding(horizontal = Size40dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
fun DefaultBookmarkList(
defaultBookmarks: BookmarkListState,
openDefaultBookmarks: () -> Unit,
) {
Text(message)
Spacer(modifier = StdVertSpacer)
val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle()
ListItem(
modifier = Modifier.clickable(onClick = openDefaultBookmarks),
headlineContent = {
Text(stringRes(R.string.bookmarks_title), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringRes(R.string.bookmarks_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.BookmarkBorder,
contentDescription = stringRes(R.string.bookmark_list_icon_label),
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
BookmarkMembershipStatusAndNumberDisplay(
modifier = Modifier.align(Alignment.CenterHorizontally),
postBookmarksSize = bookmarkState.public.size + bookmarkState.private.size,
articleBookmarksSize = 0,
)
}
},
)
}
@@ -34,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -49,7 +50,9 @@ fun ListOfBookmarkGroupsScreen(
nav: INav,
) {
ListOfBookmarkGroupsFeed(
defaultBookmarks = accountViewModel.account.bookmarkState,
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
openBookmarkGroup = { identifier, bookmarkType ->
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
@@ -84,7 +87,9 @@ fun ListOfBookmarkGroupsScreen(
@Composable
fun ListOfBookmarkGroupsFeed(
defaultBookmarks: BookmarkListState,
listSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
addBookmarkGroup: () -> Unit,
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -109,7 +114,9 @@ fun ListOfBookmarkGroupsFeed(
).fillMaxHeight(),
) {
ListOfBookmarkGroupsFeedView(
defaultBookmarks = defaultBookmarks,
groupListFeedSource = listSource,
openDefaultBookmarks = openDefaultBookmarks,
onOpenItem = openBookmarkGroup,
onRenameItem = renameBookmarkGroup,
onItemDescriptionChange = changeBookmarkGroupDescription,
@@ -1,105 +0,0 @@
/**
* 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.bookmarkgroups.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@Composable
fun NewBookmarkGroupCreationDialog(
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
onCreateGroup: (name: String, description: String?) -> Unit,
) {
val newGroupName = remember { mutableStateOf("") }
val newGroupDescription = remember { mutableStateOf<String?>(null) }
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "New Bookmark Group",
)
}
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
// For the new bookmark group name
TextField(
value = newGroupName.value,
onValueChange = { newGroupName.value = it },
label = {
Text(text = "Group name")
},
)
Spacer(modifier = DoubleVertSpacer)
// For the group description
TextField(
value =
(if (newGroupDescription.value != null) newGroupDescription.value else "").toString(),
onValueChange = { newGroupDescription.value = it },
label = {
Text(text = "Group description(optional)")
},
)
}
},
confirmButton = {
Button(
onClick = {
onCreateGroup(newGroupName.value, newGroupDescription.value)
onDismiss()
},
) {
Text("Create Group")
}
},
dismissButton = {
Button(
onClick = onDismiss,
) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -75,8 +74,6 @@ private fun ListManagementView(
accountViewModel: AccountViewModel,
nav: INav,
) {
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
@@ -95,13 +92,54 @@ private fun ListManagementView(
).consumeWindowInsets(contentPadding)
.imePadding(),
) {
if (bookmarkGroups.isEmpty()) {
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
} else {
ListManagementViewBody(note, accountViewModel, nav)
}
}
}
@Composable
private fun ListManagementViewBody(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
.collectAsStateWithLifecycle()
val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks
.collectAsStateWithLifecycle()
LazyColumn(
state = rememberLazyListState(),
modifier = Modifier.fillMaxWidth(),
) {
item {
val maybePublicBookmark = defaultBookmarks.public.contains(note)
val maybePrivateBookmark = defaultBookmarks.private.contains(note)
BookmarkGroupManagementItem(
modifier = Modifier.fillMaxWidth().animateItem(),
listTitle = stringRes(R.string.bookmarks_title),
isPublicMemberBookmark = maybePublicBookmark,
isPrivateMemberBookmark = maybePrivateBookmark,
totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size,
totalArticleBookmarkSize = 0,
onClick = {
nav.nav(Route.Bookmarks)
},
onAddBookmarkToGroup = { shouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.addBookmark(note, shouldBePrivate)
}
},
onRemoveBookmarkFromGroup = {
accountViewModel.launchSigner {
accountViewModel.account.removeBookmark(note)
}
},
)
}
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList ->
val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address }
val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address }
@@ -137,6 +175,3 @@ private fun ListManagementView(
}
}
}
}
}
}
@@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
@@ -74,8 +73,6 @@ private fun ListManagementView(
accountViewModel: AccountViewModel,
nav: INav,
) {
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
@@ -94,13 +91,54 @@ private fun ListManagementView(
).consumeWindowInsets(contentPadding)
.imePadding(),
) {
if (bookmarkGroups.isEmpty()) {
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
} else {
ListManagementViewBody(note, accountViewModel, nav)
}
}
}
@Composable
private fun ListManagementViewBody(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
.collectAsStateWithLifecycle()
val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks
.collectAsStateWithLifecycle()
LazyColumn(
state = rememberLazyListState(),
modifier = Modifier.fillMaxWidth(),
) {
item {
val maybePublicBookmark = defaultBookmarks.public.contains(note)
val maybePrivateBookmark = defaultBookmarks.private.contains(note)
BookmarkGroupManagementItem(
modifier = Modifier.fillMaxWidth().animateItem(),
listTitle = stringRes(R.string.bookmarks_title),
isPublicMemberBookmark = maybePublicBookmark,
isPrivateMemberBookmark = maybePrivateBookmark,
totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size,
totalArticleBookmarkSize = 0,
onClick = {
nav.nav(Route.Bookmarks)
},
onAddBookmarkToGroup = { shouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.addBookmark(note, shouldBePrivate)
}
},
onRemoveBookmarkFromGroup = {
accountViewModel.launchSigner {
accountViewModel.account.removeBookmark(note)
}
},
)
}
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList ->
val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex }
val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex }
@@ -136,6 +174,3 @@ private fun ListManagementView(
}
}
}
}
}
}
@@ -972,7 +972,6 @@ open class ShortNotePostViewModel :
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed)
val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state)
val uploadVoiceExceptionMessage: (String) -> String = { detail ->
stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
}
@@ -36,5 +36,5 @@ fun BookmarkTabHeader(
) {
val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel)
Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}")
Text(text = "$userBookmarks ${stringRes(R.string.bookmarks_title)}")
}
@@ -215,13 +215,13 @@ val LightSelectedReactionBoxModifier =
val DarkChannelNotePictureModifier =
Modifier
.size(30.dp)
.size(20.dp)
.clip(shape = CircleShape)
.border(2.dp, DarkColorPalette.background, CircleShape)
val LightChannelNotePictureModifier =
Modifier
.size(30.dp)
.size(20.dp)
.clip(shape = CircleShape)
.border(2.dp, LightColorPalette.background, CircleShape)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

@@ -158,6 +158,7 @@
<string name="record_a_message">Hangüzenet rögzítése</string>
<string name="record_a_message_title">Hangüzenet rögzítése</string>
<string name="record_a_message_description">Hangüzenet rögzítéséhez kattintson és tartsa lenyomva a gombot</string>
<string name="re_record">Újrarögzítés</string>
<string name="recording_indicator_description">Rögzítés</string>
<string name="recording_indicator_with_time">%1$s rögzítése</string>
<string name="uploading">Feltöltés…</string>
@@ -480,8 +481,8 @@
<string name="privacy_options">Adatvédelmi beállítások</string>
<string name="connect_via_tor_short">Tor és Orbot beállítása</string>
<string name="connect_via_tor">Kapcsolódás az Orbot beállításain keresztül</string>
<string name="connect_via_tor1">Beállítás</string>
<string name="connect_via_tor2">Tor beállítások</string>
<string name="connect_via_tor1">Módosítás</string>
<string name="connect_via_tor2">Tor Beállítások</string>
<string name="do_you_really_want_to_disable_tor_title">Kapcsolat bontása az Orbottal / Torral?</string>
<string name="do_you_really_want_to_disable_tor_text">Az Ön adatai azonnal átkerülnek a normál hálózatra</string>
<string name="yes">Igen</string>
@@ -158,6 +158,7 @@
<string name="record_a_message">Nagraj wiadomość</string>
<string name="record_a_message_title">Nagrywanie wiadomości</string>
<string name="record_a_message_description">Kliknij i przytrzymaj aby nagrać wiadomość</string>
<string name="re_record">Nagraj ponownie</string>
<string name="recording_indicator_description">Nagrywanie</string>
<string name="recording_indicator_with_time">Nagrywanie %1$s</string>
<string name="uploading">Wgrywanie…</string>
@@ -356,6 +357,8 @@
<string name="block_only">Zablokuj</string>
<string name="manual_zaps">Ręczny podział zapów</string>
<string name="bookmarks">Zakładki</string>
<string name="bookmarks_title">Domyślne zakładki</string>
<string name="bookmarks_explainer">Twoje domyślne zakładki obsługiwane przez wielu klientów</string>
<string name="drafts">Projekty</string>
<string name="private_bookmarks">Prywatne Zakładki</string>
<string name="public_bookmarks">Publiczne zakładki</string>
@@ -21,6 +21,7 @@
<string name="illegal_behavior">Noqonuniy xatti-harakat</string>
<string name="other">Boshqa sabab</string>
<string name="harassment">Taqib</string>
<string name="violence">Zo\'ravon</string>
<string name="unknown">Noma\'lum</string>
<string name="relay_icon">Relay belgisi</string>
<string name="unknown_author">Nomaʼlum muallif</string>
@@ -45,4 +46,26 @@
<string name="login_with_a_private_key_to_be_able_to_reply">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat oqish mumkin. Xabar yozish uchun, maxfiy kalit bilan avtorizatsiyadan oting</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat oqish mumkin. Postni targib qilish uchun, maxfiy kalit bilan avtorizatsiyadan oting</string>
<string name="login_with_a_private_key_to_like_posts">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat oqish mumkin. Postga reaksiya qilish uchun, maxfiy kalit bilan avtorizatsiyadan oting</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat oqish huquqiga ega. Obuna bolish uchun shaxsiy kalit bilan tizimga kiring</string>
<string name="login_with_a_private_key_to_be_able_to_upload">Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat oqish huquqiga ega. Yuklash uchun shaxsiy kalit bilan tizimga kiring</string>
<string name="login_with_a_private_key_to_be_able_to_sign_events">Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat oqish huquqiga ega. Xabarlarni imzolash uchun shaxsiy kalit bilan tizimga kiring</string>
<string name="unauthorized_exception">Ruxsatsiz deshifrlash</string>
<string name="unauthorized_exception_description">Imzolovchi ushbu operatsiyani amalga oshirish uchun zarur boʻlgan shifrlashni ochishga ruxsat bermadi. Imzolovchi ilovangizda NIP-44 shifrlashni ochish funksiyasini yoqing va qaytadan urinib koʻring</string>
<string name="signer_not_found_exception">Imzolovchi ilova topilmadi</string>
<string name="signer_not_found_exception_description">Imzolovchi ilova oʻchirib tashlandimi? Imzolovchi oʻrnatilganligini va ushbu hisobga ega ekanligini tekshiring. Agar imzolovchi ilova oʻzgargan boʻlsa, tizimdan chiqing va qayta kiring.</string>
<string name="zaps">Zeplar</string>
<string name="view_count">Koʻrishlar soni</string>
<string name="boost">Targʻib qilish</string>
<string name="boosted">Targ\'ib qilidi</string>
<string name="edited">Tahrirlangan</string>
<string name="edited_number">Tahrir № %1$s</string>
<string name="original">Asl</string>
<string name="quote">Iqtibos qilish</string>
<string name="fork">Nusxa olish</string>
<string name="propose_an_edit">Tahrir taklif qilish</string>
<string name="add">Qo\'shish</string>
<string name="replying_to">"Kimga javob yozilmoqda: "</string>
<string name="and">" va "</string>
<string name="in_channel">"bu kanalda: "</string>
<string name="profile_banner">Profil banneri</string>
</resources>
@@ -158,6 +158,7 @@
<string name="record_a_message">录制消息</string>
<string name="record_a_message_title">录制消息</string>
<string name="record_a_message_description">点按以录制消息</string>
<string name="re_record">重新录制</string>
<string name="recording_indicator_description">正在录制</string>
<string name="recording_indicator_with_time">正在录制 %1$s</string>
<string name="uploading">上传中…</string>
+44 -3
View File
@@ -17,6 +17,8 @@
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
<string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</string>
<string name="relay_notice">Relay Notice</string>
<string name="duplicated_post">Duplicated Post</string>
<string name="spam">Spam</string>
<string name="spam_description">The number of spamming events coming from this relay</string>
<string name="impersonation">Impersonation</string>
@@ -133,6 +135,7 @@
<string name="relay_address">Relay Address</string>
<string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="error">Error</string>
<string name="errors">Errors</string>
<string name="errors_description">The number of connection errors in this session</string>
<string name="home_feed">Home Feed</string>
@@ -391,6 +394,8 @@
<string name="manual_zaps">Manual Zap Splits</string>
<string name="bookmarks">Bookmarks</string>
<string name="bookmarks_title">Default Bookmarks</string>
<string name="bookmarks_explainer">Your default Bookmarks that many clients support</string>
<string name="drafts">Drafts</string>
<string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public Bookmarks</string>
@@ -745,28 +750,64 @@
<string name="read_from_relay_description">The amount in bytes that was received from this relay, including filters and events</string>
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
<string name="owner">Owner</string>
<string name="self">Service Key</string>
<string name="running_software">Running %1$s</string>
<string name="running_software_version">Running %1$s (%2$s)</string>
<string name="version">Version</string>
<string name="software">Software</string>
<string name="contact">Contact</string>
<string name="supports">Supported NIPs</string>
<string name="supported_grasps">Supported Grasps</string>
<string name="admission_fees">Admission Fees</string>
<string name="admission">Admission</string>
<string name="subscription">Subscription</string>
<string name="publication">Publication</string>
<string name="payment_link">Payments %1$s</string>
<string name="payments_url">Payments url</string>
<string name="target_audience">Target Audience</string>
<string name="policies_and_links">Policies &amp; Links</string>
<string name="fees_and_payments">Fees &amp; Payments</string>
<string name="limitations">Limitations</string>
<string name="countries">Countries</string>
<string name="languages">Languages</string>
<string name="tags">Tags</string>
<string name="topics">Topics</string>
<string name="all_countries">All Countries</string>
<string name="all_languages">All Languages</string>
<string name="posting_policy">Posting policy</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="terms_and_conditions">Terms &amp; Conditions</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Errors and Notices from this Relay</string>
<string name="message_length">Message length</string>
<string name="subscriptions">Subscriptions</string>
<string name="filters">Filters</string>
<string name="subscription_id_length">Subscription id length</string>
<string name="minimum_prefix">Minimum prefix</string>
<string name="maximum_event_tags">Maximum event tags</string>
<string name="minimum_prefix">Min Prefix</string>
<string name="maximum_event_tags">Max Event Tags</string>
<string name="content_length">Content length</string>
<string name="minimum_pow">Minimum PoW</string>
<string name="max_content_length">Max Content Length</string>
<string name="discards_older_than">Discards older than</string>
<string name="accepts_up_to">Accepts up to</string>
<string name="time_in_the_future">%1$s in the future</string>
<string name="time_in_the_past">%1$s ago</string>
<string name="amount_in_zeros">%1$s zeros</string>
<string name="amount_in_bits">%1$s bits</string>
<string name="event_retention">Event Retention</string>
<string name="content_size">Content Size</string>
<string name="connectivity">Connectivity</string>
<string name="access_control">Access Control</string>
<string name="minimum_pow">Min PoW Difficulty</string>
<string name="auth">Auth</string>
<string name="auth_required">Auth Required</string>
<string name="payment">Payment</string>
<string name="payment_required">Payment Required</string>
<string name="max_message_length">Max Message Length</string>
<string name="max_subs">Max Subscriptions</string>
<string name="max_filters_per_sub">Max Filters per Sub</string>
<string name="max_limit_events_returning">Max Limit (Events Returning)</string>
<string name="default_limit">Default Limit (Events Returning)</string>
<string name="max_subid_length">Max SubID Length</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Redeem</string>
<string name="cashu_redeem_to_zap">Send to Zap Wallet</string>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.util
import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time
import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat
import java.util.Locale
@@ -81,6 +82,50 @@ fun timeAgo(
}
}
fun timeDiffAgoLong(timeDifference: Int): String =
when {
timeDifference > TimeUtils.ONE_YEAR -> {
(timeDifference / TimeUtils.ONE_YEAR).toString() + " years"
}
timeDifference > TimeUtils.ONE_MONTH -> {
(timeDifference / TimeUtils.ONE_MONTH).toString() + " months"
}
timeDifference > TimeUtils.ONE_DAY -> {
(timeDifference / TimeUtils.ONE_DAY).toString() + " days"
}
timeDifference > TimeUtils.ONE_HOUR -> {
(timeDifference / TimeUtils.ONE_HOUR).toString() + " hours"
}
timeDifference > TimeUtils.ONE_MINUTE -> {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + " minutes"
}
else -> {
"now"
}
}
fun timeDiffAgoShortish(timeDifference: Int): String =
when {
timeDifference > TimeUtils.ONE_YEAR -> {
(timeDifference / TimeUtils.ONE_YEAR).toString() + " yrs"
}
timeDifference > TimeUtils.ONE_MONTH -> {
(timeDifference / TimeUtils.ONE_MONTH).toString() + " mos"
}
timeDifference > TimeUtils.ONE_DAY -> {
(timeDifference / TimeUtils.ONE_DAY).toString() + " days"
}
timeDifference > TimeUtils.ONE_HOUR -> {
(timeDifference / TimeUtils.ONE_HOUR).toString() + " hrs"
}
timeDifference > TimeUtils.ONE_MINUTE -> {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + " mins"
}
else -> {
"now"
}
}
/**
* Formats a Unix timestamp as a date string.
* For recent dates (< 1 day), returns the provided today string.
+2 -2
View File
@@ -53,7 +53,7 @@ torAndroid = "0.4.8.21.1"
translate = "17.0.3"
unifiedpush = "3.0.10"
urlDetector = "0.1.23"
vico-charts = "2.3.6"
vico-charts = "2.4.0"
zelory = "3.0.1"
zoomable = "2.9.0"
zxing = "3.5.4"
@@ -174,6 +174,6 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" }
stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -30,28 +28,14 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Before
import org.junit.Test
class AddressableTest {
private lateinit var db: EventStore
class AddressableTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testReplacingAddressables() {
fun testReplacingAddressables() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
@@ -79,7 +63,8 @@ class AddressableTest {
}
@Test
fun testBlockingOldAddressables() {
fun testBlockingOldAddressables() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
@@ -112,7 +97,8 @@ class AddressableTest {
}
@Test
fun testTriggersIndexUsage() {
fun testTriggersIndexUsage() =
forEachDB { db ->
val explainer =
db.store.explainQuery(
"""
@@ -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.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Before
open class BaseDBTest {
private lateinit var dbs: MutableMap<String, EventStore>
fun DefaultIndexingStrategy.name(): String =
"""
indexEventsByCreatedAtAlone=$indexEventsByCreatedAtAlone
indexTagsByCreatedAtAlone=$indexTagsByCreatedAtAlone
indexTagsWithKindAndPubkey=$indexTagsWithKindAndPubkey
useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy
""".trimIndent()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
val booleans = listOf(true, false)
dbs = mutableMapOf<String, EventStore>()
// tests all possible DBs
for (indexEventsByCreatedAtAlone in booleans) {
for (indexTagsByCreatedAtAlone in booleans) {
for (indexTagsWithKindAndPubkey in booleans) {
for (useAndIndexIdOnOrderBy in booleans) {
val indexStrategy =
DefaultIndexingStrategy(
indexEventsByCreatedAtAlone,
indexTagsByCreatedAtAlone,
indexTagsWithKindAndPubkey,
useAndIndexIdOnOrderBy,
)
dbs[indexStrategy.name()] =
EventStore(
context = context,
dbName = null,
indexStrategy = indexStrategy,
)
}
}
}
}
}
@After
fun tearDown() {
dbs.forEach { it.value.close() }
}
fun forEachDB(action: (EventStore) -> Unit) {
dbs.forEach {
println("--------------------")
println(it.key)
println("--------------------")
action(it.value)
}
}
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -30,18 +28,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class BasicTest {
private lateinit var db: SQLiteEventStore
class BasicTest : BaseDBTest() {
val signer = NostrSignerSync()
companion object Companion {
companion object {
val profile =
MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
@@ -82,135 +76,133 @@ class BasicTest {
)
}
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = SQLiteEventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testInsertDeleteEvent() {
fun testInsertDeleteEvent() =
forEachDB { db ->
val note = signer.sign(TextNoteEvent.build("test1"))
db.insertEvent(note)
db.store.insertEvent(note)
db.assertQuery(note, Filter(ids = listOf(note.id)))
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
db.delete(note.id)
db.store.delete(note.id)
db.assertQuery(null, Filter(ids = listOf(note.id)))
db.store.assertQuery(null, Filter(ids = listOf(note.id)))
db.insertEvent(note)
db.store.insertEvent(note)
db.assertQuery(note, Filter(ids = listOf(note.id)))
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
}
@Test
fun testEmptyFilter() {
fun testEmptyFilter() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
db.insertEvent(note1)
db.store.insertEvent(note1)
db.assertQuery(note1, Filter())
db.store.assertQuery(note1, Filter())
db.insertEvent(note2)
db.store.insertEvent(note2)
db.assertQuery(listOf(note2, note1), Filter())
db.store.assertQuery(listOf(note2, note1), Filter())
}
@Test
fun testLimitFilter() {
fun testLimitFilter() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3))
val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4))
db.insertEvent(note1)
db.store.insertEvent(note1)
db.assertQuery(note1, Filter(limit = 1))
db.store.assertQuery(note1, Filter(limit = 1))
db.insertEvent(note2)
db.insertEvent(note3)
db.insertEvent(note4)
db.store.insertEvent(note2)
db.store.insertEvent(note3)
db.store.insertEvent(note4)
db.assertQuery(listOf(note4), Filter(limit = 1))
db.store.assertQuery(listOf(note4), Filter(limit = 1))
}
@Test
fun testPubkeyTag() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testPubkeyTag() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(
db.store.assertQuery(
comment,
Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))),
)
}
@Test
fun testTagOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testTagOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
}
@Test
fun testTagWithSinceOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testTagWithSinceOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1),
)
db.assertQuery(
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt),
)
db.assertQuery(
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testTagWithUntilOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1),
)
db.assertQuery(
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt),
)
db.assertQuery(
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnlyEmitting() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testTagWithUntilOnlyEmitting() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
db.store.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
assertEquals(comment.toJson(), event.toJson())
}
}
@Test
fun hashCodeTest() {
fun hashCodeTest() =
forEachDB { db ->
val note1 =
signer.sign(
TextNoteEvent.build("test1") {
@@ -230,9 +222,9 @@ class BasicTest {
},
)
db.insertEvent(note1)
db.insertEvent(note2)
db.insertEvent(note3)
db.store.insertEvent(note1)
db.store.insertEvent(note2)
db.store.insertEvent(note3)
val list =
db.query<TextNoteEvent>(
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -33,29 +31,15 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class DeletionTest {
private lateinit var db: EventStore
class DeletionTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testInsertDeleteEvent() {
fun testInsertDeleteEvent() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1"))
val note2 = signer.sign(TextNoteEvent.build("test2"))
val note3 = signer.sign(TextNoteEvent.build("test3"))
@@ -92,7 +76,8 @@ class DeletionTest {
}
@Test
fun testInsertDeleteEventOfAddressable() {
fun testInsertDeleteEventOfAddressable() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
@@ -137,7 +122,8 @@ class DeletionTest {
}
@Test
fun testInsertDeleteEventOfAddressable2() {
fun testInsertDeleteEventOfAddressable2() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
@@ -175,7 +161,8 @@ class DeletionTest {
}
@Test
fun testInsertDeleteWrap() {
fun testInsertDeleteWrap() =
forEachDB { db ->
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
@@ -219,34 +206,39 @@ class DeletionTest {
}
@Test
fun testTriggersIndexUsage() {
val sql =
"""
SELECT 1 FROM event_tags
INNER JOIN event_headers
ON event_headers.row_id = event_tags.event_header_row_id
WHERE
event_tags.tag_hash IN (3221122, 223322) AND
event_headers.kind = 5 AND
event_headers.created_at >= 1766686500 AND
event_headers.pubkey_owner_hash = 22332323
""".trimIndent()
fun testTriggersIndexUsage() =
forEachDB { db ->
var sql = db.store.deletionModule.rejectDeletedEventsSQLTemplate()
val explainer =
db.store.explainQuery(sql)
sql = sql.replace("NEW.etag_hash", "3221122")
sql = sql.replace("NEW.atag_hash", "223322")
sql = sql.replace("NEW.pubkey_owner_hash", "22332323")
sql = sql.replace("NEW.created_at", "1766686500")
val explainer = db.store.explainQuery(sql)
if (db.indexStrategy.indexTagsWithKindAndPubkey) {
TestCase.assertEquals(
"""
${sql.replace("\n","\n ")}
SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
""".trimIndent(),
|$sql
| SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?)
""".trimMargin(),
explainer,
)
} else {
TestCase.assertEquals(
"""
|$sql
| SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?)
""".trimMargin(),
explainer,
)
}
}
@Test
fun testDeleteById() {
fun testDeleteById() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
@@ -272,7 +264,8 @@ class DeletionTest {
}
@Test
fun testDeleteAddressable() {
fun testDeleteAddressable() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
@@ -302,7 +295,8 @@ class DeletionTest {
}
@Test
fun testDeleteAddressablesSingleKind() {
fun testDeleteAddressablesSingleKind() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
@@ -335,7 +329,8 @@ class DeletionTest {
}
@Test
fun testDeleteAddressablesMultipleKinds() {
fun testDeleteAddressablesMultipleKinds() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
@@ -376,7 +371,8 @@ class DeletionTest {
}
@Test
fun testDeleteReplaceables() {
fun testDeleteReplaceables() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
@@ -20,38 +20,22 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ExpirationTest {
private lateinit var db: EventStore
class ExpirationTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testDeletingExpiredEvents() {
fun testDeletingExpiredEvents() =
forEachDB { db ->
val time = TimeUtils.now()
val noteSafe =
@@ -83,7 +67,8 @@ class ExpirationTest {
}
@Test
fun testInsertingExpiredEvents() {
fun testInsertingExpiredEvents() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 =
@@ -0,0 +1,165 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import org.junit.Test
class FilterMatcherTest : BaseDBTest() {
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(ids = listOf(id)))
db.assertQuery(note, Filter(ids = listOf(id, rootETag)))
db.assertQuery(null, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(authors = listOf(pubkey)))
db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag)))
db.assertQuery(null, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(kinds = listOf(kind)))
db.assertQuery(note, Filter(kinds = listOf(kind, 1221)))
db.assertQuery(null, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(since = createdAt))
db.assertQuery(note, Filter(since = createdAt - 1))
db.assertQuery(null, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(until = createdAt))
db.assertQuery(note, Filter(until = createdAt + 1))
db.assertQuery(null, Filter(until = createdAt - 1))
}
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.Log
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.zip.GZIPInputStream
@@ -82,6 +83,7 @@ class LargeDBTests {
}
@Test
@Ignore("Not testing")
fun insertDatabase() {
events.forEach { event ->
try {
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -30,28 +28,14 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Before
import org.junit.Test
class ReplaceableTest {
private lateinit var db: EventStore
class ReplaceableTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testReplacing() {
fun testReplacing() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
@@ -79,7 +63,8 @@ class ReplaceableTest {
}
@Test
fun testBlockingOldVersions() {
fun testBlockingOldVersions() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
@@ -112,18 +97,18 @@ class ReplaceableTest {
}
@Test
fun testTriggersIndexUsageKind0() {
val explainer =
db.store.explainQuery(
fun testTriggersIndexUsageKind0() =
forEachDB { db ->
val sql =
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
""".trimIndent(),
)
event_headers.created_at < 1766686500
""".trimIndent()
val explainer = db.store.explainQuery(sql)
assertEquals(
"""
@@ -131,27 +116,26 @@ class ReplaceableTest {
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?)
event_headers.created_at < 1766686500
SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
""".trimIndent(),
explainer,
)
}
@Test
fun testTriggersIndexUsageKind3() {
val explainer =
db.store.explainQuery(
fun testTriggersIndexUsageKind3() =
forEachDB { db ->
val sql =
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
""".trimIndent(),
)
event_headers.created_at < 1766686500
""".trimIndent()
val explainer = db.store.explainQuery(sql)
assertEquals(
"""
@@ -159,9 +143,8 @@ class ReplaceableTest {
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?)
event_headers.created_at < 1766686500
SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
""".trimIndent(),
explainer,
)
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -30,29 +28,15 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class RightToVanishTest {
private lateinit var db: EventStore
class RightToVanishTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null, relayUrl = "testUrl")
}
@After
fun tearDown() {
db.close()
}
@Test
fun testInsertDeleteEvent() {
fun testInsertDeleteEvent() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1))
@@ -66,7 +50,7 @@ class RightToVanishTest {
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val vanish = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2))
val vanish = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
@@ -90,7 +74,8 @@ class RightToVanishTest {
}
@Test
fun testInsertDeleteGiftWrap() {
fun testInsertDeleteGiftWrap() =
forEachDB { db ->
val time = TimeUtils.now()
val me = NostrSignerSync()
@@ -106,14 +91,14 @@ class RightToVanishTest {
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2))
val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(randomVanishToWrap)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2))
val vanish = me.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
@@ -20,20 +20,14 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.After
import org.junit.Before
import org.junit.Test
class SearchTest {
private lateinit var db: SQLiteEventStore
companion object Companion {
class SearchTest : BaseDBTest() {
companion object {
val profile =
MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
@@ -74,33 +68,23 @@ class SearchTest {
)
}
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = SQLiteEventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testTagWithSearch() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testTagWithSearch() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(null, Filter(search = "testing1"))
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing"))
db.delete(comment.id)
db.store.delete(comment.id)
db.assertQuery(null, Filter(search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.insertEvent(comment)
db.store.insertEvent(comment)
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
@@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
class DeletionRequestModule(
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IModule {
fun rejectDeletedEventsSQLTemplate(): String =
if (indexStrategy.indexTagsWithKindAndPubkey) {
"""
|SELECT 1 FROM event_tags
|WHERE
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
| event_tags.kind = 5 AND
| event_tags.pubkey_hash = NEW.pubkey_owner_hash AND
| event_tags.created_at >= NEW.created_at
""".trimMargin()
} else {
"""
|SELECT 1 FROM event_tags
|WHERE
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
| event_tags.kind = 5 AND
| event_tags.created_at >= NEW.created_at AND
| event_tags.pubkey_hash = NEW.pubkey_owner_hash
""".trimMargin()
}
/**
* Creates a trigger to reject events that have been
* deleted by ID or ATag including GiftWraps that
* must be checked against the p-tag (pubkey_owner_hash)
*/
override fun create(db: SQLiteDatabase) {
val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ")
db.execSQL(
"""
CREATE TRIGGER reject_deleted_events
BEFORE INSERT ON event_headers
FOR EACH ROW
BEGIN
-- Check for ID-based deletion record
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
WHERE EXISTS (
SELECT 1 FROM event_tags
INNER JOIN event_headers
ON event_headers.row_id = event_tags.event_header_row_id
WHERE
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
event_headers.kind = 5 AND
event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND
event_headers.created_at >= NEW.created_at
$sql
);
END;
""".trimIndent(),
@@ -20,23 +20,16 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.EventFactory
class EventIndexesModule(
val fts: FullTextSearchModule,
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IModule {
override fun create(db: SQLiteDatabase) {
db.execSQL(
@@ -63,19 +56,53 @@ class EventIndexesModule(
CREATE TABLE event_tags (
event_header_row_id INTEGER NOT NULL,
tag_hash INTEGER NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
pubkey_hash INTEGER NOT NULL,
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
)
""".trimIndent(),
)
// queries by ID (load events)
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
db.execSQL("CREATE INDEX query_by_kind_pubkey_dtag_idx ON event_headers (kind, pubkey, d_tag)")
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at desc, id)")
// need to check if this is actually needed.
db.execSQL("CREATE INDEX query_by_created_at_kind_key ON event_headers (created_at desc, kind, pubkey)")
val orderBy =
if (indexStrategy.useAndIndexIdOnOrderBy) {
"created_at DESC, id ASC"
} else {
"created_at DESC"
}
// queries by limit (latest records), since, until (sync all) alone without any filter by kind.. rare
if (indexStrategy.indexEventsByCreatedAtAlone) {
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers ($orderBy)")
}
// queries by kind only, mostly used in Global Feeds when author is not important.
db.execSQL("CREATE INDEX query_by_kind_created ON event_headers (kind, $orderBy)")
// queries by kind + pubkey, but not d-tag, even if they are replaceables and addressables, by date.
db.execSQL("CREATE INDEX query_by_kind_pubkey_created ON event_headers (kind, pubkey, $orderBy)")
// makes deletions on the event_header fast
db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)")
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, event_header_row_id)")
// ---------------------------------------------------------------------------
// These next 3 are a very slow indexes (80% of the insert time goes here)
// ---------------------------------------------------------------------------
if (indexStrategy.indexTagsByCreatedAtAlone) {
// First one is only needed if the user is searching by tags without a kind.
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
}
// This is the default index for most clients: tags by specific kinds that are supported by the client.
db.execSQL("CREATE INDEX query_by_tags_hash_kind ON event_tags (tag_hash, kind, created_at DESC)")
// this one is to allow search of tags by kind and author at the same time: NIP-04 DMs, reports,
if (indexStrategy.indexTagsWithKindAndPubkey) {
db.execSQL("CREATE INDEX query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
}
// Prevent updates to maintain immutability
db.execSQL(
@@ -117,9 +144,9 @@ class EventIndexesModule(
val sqlInsertTags =
"""
INSERT OR ROLLBACK INTO event_tags
(event_header_row_id, tag_hash)
(event_header_row_id, tag_hash, created_at, kind, pubkey_hash)
VALUES
(?,?)
(?,?,?,?,?)
""".trimIndent()
fun insert(
@@ -169,7 +196,7 @@ class EventIndexesModule(
// rebalancing the tree every new insert
val indexableTags = ArrayList<Long>()
for (idx in event.tags.indices) {
if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) {
if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) {
indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
}
}
@@ -177,387 +204,17 @@ class EventIndexesModule(
indexableTags.forEach {
stmtTags.bindLong(1, headerId)
stmtTags.bindLong(2, it)
stmtTags.bindLong(3, event.createdAt)
stmtTags.bindLong(4, kindLong)
stmtTags.bindLong(5, pubkeyHash)
stmtTags.executeInsert()
}
return headerId
}
fun planQuery(
filter: Filter,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher)
return if (rowIdSubQuery == null) {
val query = makeEverythingQuery()
db.explainQuery(query)
} else {
val query = makeQueryIn(rowIdSubQuery.sql)
db.explainQuery(query, rowIdSubQuery.args.toTypedArray())
}
}
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
): List<T> {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.runQuery(makeEverythingQuery())
} else {
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args)
}
}
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.runQuery(makeEverythingQuery(), onEach = onEach)
} else {
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach)
}
}
fun planQuery(
filters: List<Filter>,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher)
return if (rowIdSubQuery == null) {
val query = makeEverythingQuery()
db.explainQuery(query)
} else {
val query = makeQueryIn(rowIdSubQuery.sql)
db.explainQuery(query, rowIdSubQuery.args.toTypedArray())
}
}
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
): List<T> {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery())
return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args)
}
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db))
if (rowIdSubqueries == null) {
db.runQuery(makeEverythingQuery(), onEach = onEach)
} else {
db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args, onEach)
}
}
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id"
private fun makeQueryIn(rowIdQuery: String) =
"""
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
INNER JOIN (
$rowIdQuery
) AS filtered
ON event_headers.row_id = filtered.row_id
ORDER BY created_at DESC, id
""".trimIndent()
private fun <T : Event> SQLiteDatabase.runQuery(
sql: String,
args: List<String> = emptyList(),
): List<T> =
rawQuery(sql, args.toTypedArray()).use { cursor ->
ArrayList<T>(cursor.count).apply {
while (cursor.moveToNext()) {
add(cursor.toEvent())
}
}
}
private inline fun <T : Event> SQLiteDatabase.runQuery(
sql: String,
args: List<String> = emptyList(),
onEach: (T) -> Unit,
) = rawQuery(sql, args.toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
onEach(cursor.toEvent())
}
}
private fun <T : Event> Cursor.toEvent() =
EventFactory.create<T>(
getString(0).intern(),
getString(1).intern(),
getLong(2),
getInt(3),
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
getString(5),
getString(6),
)
class RawEvent(
val id: HexKey,
val pubKey: HexKey,
val createdAt: Long,
val kind: Kind,
val jsonTags: String,
val content: String,
val sig: HexKey,
) {
fun <T : Event> toEvent() =
EventFactory.create<T>(
id.intern(),
pubKey.intern(),
createdAt,
kind,
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
content,
sig,
)
}
private fun Cursor.toRawEvent() =
RawEvent(
getString(0),
getString(1),
getLong(2),
getInt(3),
getString(4),
getString(5),
getString(6),
)
// --------------
// Counts
// -------------
fun count(
filter: Filter,
db: SQLiteDatabase,
): Int {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.countEverything()
} else {
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
}
}
fun count(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
private fun SQLiteDatabase.countIn(
rowIdQuery: String,
args: List<String>,
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
private fun SQLiteDatabase.runCount(
sql: String,
args: List<String> = emptyList(),
): Int =
rawQuery(sql, args.toTypedArray()).use { cursor ->
cursor.moveToNext()
cursor.getInt(0)
}
// --------------
// Deletes
// -------------
fun delete(
filter: Filter,
db: SQLiteDatabase,
): Int {
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdQuery == null) {
0
} else {
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
}
}
fun delete(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.runDelete(
sql: String,
args: List<String> = emptyList(),
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
// ---------------------------------
// Prepare unions of all the filters
// ---------------------------------
fun unionSubqueriesIfNeeded(
filters: List<Filter>,
hasher: TagNameValueHasher,
): RowIdSubQuery? {
val inner =
filters.mapNotNull { filter ->
prepareRowIDSubQueries(filter, hasher)
}
if (inner.isEmpty()) return null
return if (inner.size == 1) {
inner.first()
} else {
RowIdSubQuery(
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
args = inner.flatMap { it.args },
)
}
}
// ----------------------------
// Inner row id selections
// ----------------------------
fun prepareRowIDSubQueries(
filter: Filter,
hasher: TagNameValueHasher,
): RowIdSubQuery? {
if (!filter.isFilledFilter()) return null
val mustJoinSearch = (filter.search != null)
val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val hasHeaders =
with(filter) {
(ids != null) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.containsKey("d")) ||
(since != null) ||
(until != null) ||
(limit != null)
}
var defaultTagKey: String? = null
val projection =
buildString {
// always do tags if there are any
if (nonDTags.isNotEmpty()) {
append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ")
// it's quite rare to have 2 tags in the filter, but possible
nonDTags.keys.forEachIndexed { index, tagName ->
if (index > 0) {
append("INNER JOIN event_tags as event_tags$tagName ON event_tags$tagName.event_header_row_id = event_tags.event_header_row_id ")
} else {
defaultTagKey = tagName
}
}
if (hasHeaders) {
append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ")
}
if (mustJoinSearch) {
append("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id ")
}
} else if (mustJoinSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName} ")
if (hasHeaders) {
append("INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
}
} else {
// no tags and no search.
append("SELECT event_headers.row_id as row_id FROM event_headers ")
}
}
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
filter.ids?.let { equalsOrIn("event_headers.id", it) }
// range search is bad but most of the time these are up the top with few elements.
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
// there are indexes for these, starting with tags.
nonDTags.forEach { (tagName, tagValues) ->
val column =
if (defaultTagKey == null || defaultTagKey == tagName) {
"event_tags.tag_hash"
} else {
"event_tags$tagName.tag_hash"
}
equalsOrIn(
column,
tagValues.map {
hasher.hash(tagName, it)
},
)
}
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
}
}
// if search is included, SQLLite will always start here.
filter.search?.let {
if (it.isNotBlank()) {
match(fts.tableName, it)
}
}
}
val whereClause =
if (filter.limit != null) {
"${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}"
} else {
clause.conditions
}
return RowIdSubQuery("$projection WHERE $whereClause", clause.args)
}
override fun deleteAll(db: SQLiteDatabase) {
db.execSQL("DELETE FROM event_tags")
db.execSQL("DELETE FROM event_headers")
}
data class RowIdSubQuery(
val sql: String,
val args: List<String>,
)
}
@@ -29,9 +29,9 @@ class EventStore(
context: Context,
dbName: String? = "events.db",
val relayUrl: String? = "wss://quartz.local",
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IEventStore {
val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy)
val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy)
override fun insert(event: Event) = store.insertEvent(event)
@@ -23,6 +23,64 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Tag
interface IndexingStrategy {
/**
* Activate this if you see too many Filters with just LIMIT, SINCE and
* UNTIL filled up.
*
* Clients never support all kinds, so this is usually
* only done with syncing services that must download ALL kinds from
* ALL authors.
*
* The index will make these queries significantly faster, but maybe speed
* is not a requirement on Sync services. The size of this index is
* considerable.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring
* frequent reloadings of the index itself from disk.
*/
val indexEventsByCreatedAtAlone: Boolean
/**
* Activate this if you see too many Tag-centric Filters without
* kind, pubkey or id.
*
* Clients never support all kinds, so this is usually
* only done in rare usecases where the client supports all
* kinds.
*
* The index will make these queries significantly faster, but maybe speed
* is not a requirement on such services. Because this is an index in
* event tags, it becomes QUITE BIG.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring
* frequent reloadings of the index itself from disk.
*/
val indexTagsByCreatedAtAlone: Boolean
/**
* Activate this if you see too many Tag-centric Filters without
* kind AND pubkey at the same time.
*
* This is a rarely used index (reports by your follows or
* NIP-04 DMs for instance) that becomes quite large without
* major gains.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring
* frequent reloadings of the index itself from disk.
*/
val indexTagsWithKindAndPubkey: Boolean
/**
* Activate this to make sure queries are always in order when
* the same created_at exists. This will impact performance and
* the size of indexes, but it provides results that are compliant
* with the Nostr Spec
*/
val useAndIndexIdOnOrderBy: Boolean
fun shouldIndex(
kind: Int,
tag: Tag,
@@ -32,7 +90,12 @@ interface IndexingStrategy {
/**
* By default, we index all tags that have a single letter name and some value
*/
class DefaultIndexingStrategy : IndexingStrategy {
class DefaultIndexingStrategy(
override val indexEventsByCreatedAtAlone: Boolean = false,
override val indexTagsByCreatedAtAlone: Boolean = false,
override val indexTagsWithKindAndPubkey: Boolean = false,
override val useAndIndexIdOnOrderBy: Boolean = false,
) : IndexingStrategy {
override fun shouldIndex(
kind: Int,
tag: Tag,
@@ -0,0 +1,710 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
import com.vitorpamplona.quartz.utils.EventFactory
class QueryBuilder(
val fts: FullTextSearchModule,
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val indexStrategy: IndexingStrategy,
) {
// ------------
// Main methods
// ------------
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
): List<T> = db.runQuery(toSql(filter, hasher(db)))
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) = db.runQuery(toSql(filter, hasher(db)), onEach)
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
): List<T> = db.runQuery(toSql(filters, hasher(db)))
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) = db.runQuery(toSql(filters, hasher(db)), onEach)
// ---------------------------
// Raw methods for performance
// ---------------------------
fun rawQuery(
filter: Filter,
db: SQLiteDatabase,
): List<RawEvent> = db.runRawQuery(toSql(filter, hasher(db)))
fun rawQuery(
filter: Filter,
db: SQLiteDatabase,
onEach: (RawEvent) -> Unit,
) = db.runRawQuery(toSql(filter, hasher(db)), onEach)
fun rawQuery(
filters: List<Filter>,
db: SQLiteDatabase,
): List<RawEvent> = db.runRawQuery(toSql(filters, hasher(db)))
fun rawQuery(
filters: List<Filter>,
db: SQLiteDatabase,
onEach: (RawEvent) -> Unit,
) = db.runRawQuery(toSql(filters, hasher(db)), onEach)
// -----------
// Debug Tools
// -----------
fun planQuery(
filter: Filter,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val query = toSql(filter, hasher)
return db.explainQuery(query.sql, query.args.toTypedArray())
}
fun planQuery(
filters: List<Filter>,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val query = toSql(filters, hasher)
return db.explainQuery(query.sql, query.args.toTypedArray())
}
fun toSql(
filter: Filter,
hasher: TagNameValueHasher,
): QuerySpec {
val newFilter = filter.toFilterWithDTags()
if (newFilter.isSimpleQuery()) {
return makeSimpleQuery(
project = true,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
}
if (newFilter.isSimpleSearch()) {
return makeSimpleSearch(
search = newFilter.search!!,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
}
val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher)
return if (rowIdSubqueries == null) {
QuerySpec(makeEverythingQuery())
} else {
QuerySpec(
makeQueryIn(rowIdSubqueries.sql),
rowIdSubqueries.args,
)
}
}
fun toSql(
filters: List<Filter>,
hasher: TagNameValueHasher,
): QuerySpec {
if (filters.size == 1) return toSql(filters.first(), hasher)
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher)
return if (rowIdSubqueries == null) {
QuerySpec(
makeEverythingQuery(),
emptyList(),
)
} else {
QuerySpec(
makeQueryIn(rowIdSubqueries.sql),
rowIdSubqueries.args,
)
}
}
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
private fun makeQueryIn(rowIdQuery: String) =
"""
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
INNER JOIN (
$rowIdQuery
) AS filtered
ON event_headers.row_id = filtered.row_id
ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}
""".trimIndent()
private fun <T : Event> SQLiteDatabase.runQuery(query: QuerySpec): List<T> =
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
ArrayList<T>(cursor.count).apply {
while (cursor.moveToNext()) {
add(cursor.toEvent())
}
}
}
private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List<RawEvent> =
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
ArrayList<RawEvent>(cursor.count).apply {
while (cursor.moveToNext()) {
add(cursor.toRawEvent())
}
}
}
private inline fun <T : Event> SQLiteDatabase.runQuery(
query: QuerySpec,
onEach: (T) -> Unit,
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
onEach(cursor.toEvent())
}
}
private inline fun SQLiteDatabase.runRawQuery(
query: QuerySpec,
onEach: (RawEvent) -> Unit,
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
onEach(cursor.toRawEvent())
}
}
private fun <T : Event> Cursor.toEvent() =
EventFactory.create<T>(
getString(0).intern(),
getString(1).intern(),
getLong(2),
getInt(3),
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
getString(5),
getString(6),
)
private fun Cursor.toRawEvent() =
RawEvent(
getString(0),
getString(1),
getLong(2),
getInt(3),
getString(4),
getString(5),
getString(6),
)
// --------------
// Counts
// -------------
fun count(
filter: Filter,
db: SQLiteDatabase,
): Int {
val newFilter = filter.toFilterWithDTags()
if (newFilter.isSimpleQuery()) {
val sql =
makeSimpleQuery(
project = false,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
return db.countIn(sql.sql, sql.args)
}
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.countEverything()
} else {
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
}
}
fun count(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
private fun SQLiteDatabase.countIn(
rowIdQuery: String,
args: List<String>,
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
private fun SQLiteDatabase.runCount(
sql: String,
args: List<String> = emptyList(),
): Int =
rawQuery(sql, args.toTypedArray()).use { cursor ->
cursor.moveToNext()
cursor.getInt(0)
}
// --------------
// Deletes
// -------------
fun delete(
filter: Filter,
db: SQLiteDatabase,
): Int {
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdQuery == null) {
0
} else {
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
}
}
fun delete(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.runDelete(
sql: String,
args: List<String> = emptyList(),
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
// ---------------------------------
// Prepare unions of all the filters
// ---------------------------------
fun unionSubqueriesIfNeeded(
filters: List<Filter>,
hasher: TagNameValueHasher,
): QuerySpec? {
val inner =
filters.mapNotNull { filter ->
prepareRowIDSubQueries(filter, hasher)
}
if (inner.isEmpty()) return null
return if (inner.size == 1) {
inner.first()
} else {
QuerySpec(
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
args = inner.flatMap { it.args },
)
}
}
sealed class TagNameForQuery {
class InTags(
val tagName: String,
) : TagNameForQuery()
class AllTags(
val tagName: String,
val tagValueIndex: Int,
) : TagNameForQuery()
}
// ----------------------------
// Inner row id selections
// ----------------------------
fun prepareRowIDSubQueries(
filter: Filter,
hasher: TagNameValueHasher,
): QuerySpec? {
if (filter.isEmpty()) return null
val mustJoinSearch = (filter.search != null)
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap()
val reverseLookup = nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty()
val needHeaders =
with(filter) {
(ids != null) || (tags != null && tags.containsKey("d"))
}
val hasHeaders =
with(filter) {
(ids != null) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.containsKey("d")) ||
(since != null) ||
(until != null) ||
(limit != null)
}
var defaultTagKey: TagNameForQuery? = null
val projection =
buildString {
// always do tags if there are any
if (reverseLookup) {
append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags")
// it's quite rare to have 2 tags in the filter, but possible
nonDTagsIn.keys.forEachIndexed { index, tagName ->
if (defaultTagKey != null) {
append(" INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn$index.created_at = event_tags.created_at")
} else {
defaultTagKey = TagNameForQuery.InTags(tagName)
}
}
nonDTagsAll.keys.forEachIndexed { index, tagName ->
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
if (defaultTagKey != null) {
append(" INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll${index}_$valueIndex.created_at = event_tags.created_at")
} else {
defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex)
}
}
}
if (needHeaders) {
append(" INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id")
}
if (mustJoinSearch) {
append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id")
}
} else if (mustJoinSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}")
if (hasHeaders) {
append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
}
} else {
// no tags and no search.
append("SELECT event_headers.row_id as row_id FROM event_headers")
}
}
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
filter.ids?.let { equalsOrIn("event_headers.id", it) }
// it's quite rare to have 2 tags in the filter, but possible
nonDTagsIn.keys.forEachIndexed { index, tagName ->
val column =
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) {
"event_tags.tag_hash"
} else {
"event_tagsIn$index.tag_hash"
}
equalsOrIn(
column,
nonDTagsIn[tagName]!!.map {
hasher.hash(tagName, it)
},
)
}
// there are indexes for these, starting with tags.
nonDTagsAll.keys.forEachIndexed { index, tagName ->
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
val column =
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) {
"event_tags.tag_hash"
} else {
"event_tagsAll${index}_$valueIndex.tag_hash"
}
equals(column, hasher.hash(tagName, tagValue))
}
}
// range search is bad but most of the time these are up the top with few elements.
if (reverseLookup) {
filter.kinds?.let { equalsOrIn("event_tags.kind", it) }
filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) }
filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) }
filter.until?.let { lessThanOrEquals("event_tags.created_at", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
}
}
} else {
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
}
}
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
// no need to add the replaceable because query_by_kind_pubkey_created already covers it
val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false
if (isAllAddressable) {
// matches unique index kind >= 30000 AND kind < 40000
raw("(event_headers.kind >= 30000 AND event_headers.kind < 40000)")
}
}
// if search is included, SQLLite will always start here.
filter.search?.let {
if (it.isNotBlank()) {
match(fts.tableName, it)
}
}
}
val sql =
buildString {
append(projection)
if (clause.conditions.isNotEmpty()) {
append(" WHERE ${clause.conditions}")
}
if (filter.limit != null) {
if (reverseLookup) {
append(" ORDER BY event_tags.created_at DESC")
append(" LIMIT ")
append(filter.limit)
} else {
append(" ORDER BY event_headers.created_at DESC")
append(" LIMIT ")
append(filter.limit)
}
}
}
return QuerySpec(sql, clause.args)
}
private fun makeSimpleSearch(
search: String,
ids: List<HexKey>? = null,
authors: List<HexKey>? = null,
kinds: List<Kind>? = null,
dTags: List<String>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
): QuerySpec {
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
ids?.let { equalsOrIn("event_headers.id", it) }
match(fts.tableName, search)
kinds?.let { equalsOrIn("event_headers.kind", it) }
authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
dTags?.let { equalsOrIn("event_headers.d_tag", it) }
since?.let { greaterThanOrEquals("event_headers.created_at", it) }
until?.let { lessThanOrEquals("event_headers.created_at", it) }
// if this is a dTag filter, it is likely that all kinds are addressables
// and so force the use of the addressable index
if (dTags != null && kinds != null) {
if (kinds.all { it.isAddressable() }) {
// matches unique index kind >= 30000 AND kind < 40000
raw("(event_headers.kind >= 30000 AND kind < 40000)")
}
}
}
val sql =
buildString {
append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers")
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
if (clause.conditions.isNotEmpty()) {
append("\nWHERE ${clause.conditions}")
}
append("\nORDER BY event_headers.created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", event_headers.id ASC")
}
if (limit != null) {
append("\nLIMIT ")
append(limit)
}
}
return QuerySpec(sql, clause.args)
}
private fun makeSimpleQuery(
project: Boolean,
ids: List<HexKey>? = null,
authors: List<HexKey>? = null,
kinds: List<Kind>? = null,
dTags: List<String>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
): QuerySpec {
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
ids?.let { equalsOrIn("id", it) }
kinds?.let { equalsOrIn("kind", it) }
authors?.let { equalsOrIn("pubkey", it) }
// there are indexes for these, starting with tags.
dTags?.let { equalsOrIn("d_tag", it) }
since?.let { greaterThanOrEquals("created_at", it) }
until?.let { lessThanOrEquals("created_at", it) }
// if this is a dTag filter, it is likely that all kinds are addressables
// and so force the use of the addressable index
if (dTags != null && kinds != null) {
if (kinds.all { it.isAddressable() }) {
// matches unique index kind >= 30000 AND kind < 40000
raw("(kind >= 30000 AND kind < 40000)")
}
}
}
val sql =
buildString {
if (project) {
append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers")
} else {
append("SELECT row_id FROM event_headers")
}
if (clause.conditions.isNotEmpty()) {
append("\nWHERE ")
append(clause.conditions)
}
if (project) {
append("\nORDER BY created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", id ASC")
}
}
if (limit != null) {
append("\nLIMIT ")
append(limit)
}
}
return QuerySpec(sql, clause.args)
}
class FilterWithDTags(
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
val kinds: List<Kind>? = null,
val dTags: List<String>? = null,
val nonDTagsIn: Map<String, List<String>>? = null,
val nonDTagsAll: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) {
fun isSimpleSearch() =
search != null && search.isNotEmpty() &&
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
(nonDTagsAll == null || nonDTagsAll.isEmpty())
// can be resolved with just event_headers
fun isSimpleQuery() =
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
(nonDTagsAll == null || nonDTagsAll.isEmpty()) &&
(search == null || search.isEmpty())
}
fun Filter.toFilterWithDTags(): FilterWithDTags =
FilterWithDTags(
ids = ids,
authors = authors,
kinds = kinds,
dTags = tags?.get("d") ?: tagsAll?.get("d"),
nonDTagsIn = tags?.filter { it.key != "d" }?.ifEmpty { null },
nonDTagsAll = tagsAll?.filter { it.key != "d" }?.ifEmpty { null },
since = since,
until = until,
limit = limit,
search = search,
)
data class QuerySpec(
val sql: String,
val args: List<String> = emptyList(),
)
}
@@ -48,8 +48,7 @@ class ReplaceableModule : IModule {
WHERE
event_headers.kind = NEW.kind AND
event_headers.pubkey = NEW.pubkey AND
event_headers.created_at < NEW.created_at AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
event_headers.created_at < NEW.created_at;
END;
""".trimIndent(),
)
@@ -27,10 +27,13 @@ import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -38,7 +41,7 @@ class SQLiteEventStore(
val context: Context,
val dbName: String? = "events.db",
val relayUrl: String? = null,
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
companion object {
const val DATABASE_VERSION = 2
@@ -47,7 +50,7 @@ class SQLiteEventStore(
val seedModule = SeedModule()
val fullTextSearchModule = FullTextSearchModule()
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy)
val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy)
val replaceableModule = ReplaceableModule()
val addressableModule = AddressableModule()
@@ -57,6 +60,8 @@ class SQLiteEventStore(
val expirationModule = ExpirationModule()
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy)
val modules =
listOf(
seedModule,
@@ -73,6 +78,9 @@ class SQLiteEventStore(
override fun onConfigure(db: SQLiteDatabase) {
super.onConfigure(db)
// 32MB memory cache
db.execSQL("PRAGMA cache_size=-32000;")
// makes sure the FKs are sane
db.setForeignKeyConstraintsEnabled(true)
@@ -82,7 +90,14 @@ class SQLiteEventStore(
// The DB can be corrupted if the OS is shutdown before sync, which generally
// doesn't happen on Android
db.execSQL("PRAGMA synchronous = OFF")
db.execSQL("PRAGMA synchronous = OFF;")
}
fun dbSizeMB(): Int {
val f1 = context.getDatabasePath(dbName)
val f2 = context.getDatabasePath("$dbName-wal")
val total = f1.length() + f2.length()
return (total / (1024 * 1024)).toInt()
}
override fun onCreate(db: SQLiteDatabase) {
@@ -171,33 +186,72 @@ class SQLiteEventStore(
}
}
fun <T : Event> query(filter: Filter): List<T> = eventIndexModule.query(filter, readableDatabase)
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, readableDatabase)
fun <T : Event> query(filters: List<Filter>): List<T> = eventIndexModule.query(filters, readableDatabase)
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, readableDatabase)
fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = eventIndexModule.query(filter, readableDatabase, onEach)
) = queryBuilder.query(filter, readableDatabase, onEach)
fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = eventIndexModule.query(filters, readableDatabase, onEach)
) = queryBuilder.query(filters, readableDatabase, onEach)
fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase)
fun rawQuery(filter: Filter): List<RawEvent> = queryBuilder.rawQuery(filter, readableDatabase)
fun count(filters: List<Filter>): Int = eventIndexModule.count(filters, readableDatabase)
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, readableDatabase)
fun rawQuery(
filter: Filter,
onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filter, readableDatabase, onEach)
fun rawQuery(
filters: List<Filter>,
onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filters, readableDatabase, onEach)
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase)
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase)
fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase)
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, readableDatabase)
fun delete(filter: Filter) {
eventIndexModule.delete(filter, writableDatabase)
queryBuilder.delete(filter, writableDatabase)
}
fun delete(filters: List<Filter>) {
eventIndexModule.delete(filters, writableDatabase)
queryBuilder.delete(filters, writableDatabase)
}
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase)
}
class RawEvent(
val id: HexKey,
val pubKey: HexKey,
val createdAt: Long,
val kind: Kind,
val jsonTags: String,
val content: String,
val sig: HexKey,
) {
fun <T : Event> toEvent() =
EventFactory.create<T>(
id.intern(),
pubKey.intern(),
createdAt,
kind,
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
content,
sig,
)
}
@@ -21,6 +21,10 @@
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
sealed class Condition {
data class Raw(
val condition: String,
) : Condition()
data class Equals(
val column: String,
val value: Any?,
@@ -81,4 +85,6 @@ sealed class Condition {
data class Or(
val conditions: List<Condition>,
) : Condition()
class Empty : Condition()
}
@@ -38,14 +38,28 @@ class SqlSelectionBuilder(
*/
private fun buildCondition(cond: Condition): String =
when (cond) {
is Condition.Empty -> {
""
}
is Condition.Raw -> {
cond.condition
}
is Condition.Equals -> {
if (cond.value == null) {
"${cond.column} IS NULL"
} else {
selectionArgs.add(cond.value.toString())
"${cond.column} = ?"
}
}
is Condition.NotEquals -> {
if (cond.value == null) {
"${cond.column} IS NULL"
} else {
selectionArgs.add(cond.value.toString())
"${cond.column} != ?"
}
}
is Condition.GreaterThan -> {
selectionArgs.add(cond.value.toString())
"${cond.column} > ?"
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
class WhereClauseBuilder {
private val conditions = mutableListOf<Condition>()
fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) }
fun equals(
column: String,
value: Any?,
@@ -86,7 +88,7 @@ class WhereClauseBuilder {
fun and(block: WhereClauseBuilder.() -> Unit) =
apply {
val builder = WhereClauseBuilder().apply(block)
val builtCondition = builder.build()
val builtCondition = builder.buildAnd()
if (builtCondition != null) {
conditions.add(builtCondition)
}
@@ -95,22 +97,29 @@ class WhereClauseBuilder {
fun or(block: WhereClauseBuilder.() -> Unit) =
apply {
val builder = WhereClauseBuilder().apply(block)
val builtCondition = builder.build()
val builtCondition = builder.buildOr()
if (builtCondition != null) {
conditions.add(builtCondition)
}
}
fun build(): Condition? =
fun buildAnd(): Condition? =
when (conditions.size) {
0 -> null
1 -> conditions.first()
else -> Condition.And(conditions.toList())
}
fun buildOr(): Condition? =
when (conditions.size) {
0 -> null
1 -> conditions.first()
else -> Condition.Or(conditions.toList())
}
}
fun where(block: WhereClauseBuilder.() -> Unit): WhereClause {
val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList())
val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty()
return SqlSelectionBuilder(condition).build()
}
@@ -44,7 +44,13 @@ class AddressSerializer {
return try {
val parts = addressId.split(":", limit = 3)
if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) {
if (parts[0].length > 5) {
// invalid kind
Log.w("AddressableId", "Error parsing. invalid kind $addressId")
null
} else {
Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "")
}
} else {
if (addressId.startsWith("naddr1")) {
val addr = Nip19Parser.uriToRoute(addressId)?.entity
@@ -60,7 +66,7 @@ class AddressSerializer {
}
}
} catch (t: Throwable) {
Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t)
Log.w("AddressableId", "Error parsing: $addressId: ${t.message}", t)
null
}
}
@@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
typealias Kind = Int
fun Kind.isRegular() = this > 0 && this != ContactListEvent.KIND && this < 10_000
fun Kind.isEphemeral() = this >= 20_000 && this < 30_000
fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000)
@@ -54,6 +54,7 @@ object JsonMapper {
val jsonInstance =
Json {
ignoreUnknownKeys = true
isLenient = true
}
inline fun <reified T> fromJson(json: String): T = jsonInstance.decodeFromString<T>(json)
@@ -35,10 +35,10 @@ class RelayBasedFilter(
fun List<RelayBasedFilter>.groupByRelay(): Map<NormalizedRelayUrl, List<Filter>> {
val result = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
for (relayBasedFilter in this) {
if (relayBasedFilter.filter.isFilledFilter()) {
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
} else {
if (relayBasedFilter.filter.isEmpty()) {
Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}")
} else {
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
}
}
return result
@@ -128,7 +128,7 @@ open class BasicRelayClient(
} catch (e: Throwable) {
if (e is CancellationException) throw e
// doesn't expose parsing errors to lib users as errors
Log.e("BasicRelayClient", "Failure to parse message from Relay", e)
Log.e("BasicRelayClient", "Failure to parse message from Relay: $text", e)
}
}
@@ -21,8 +21,10 @@
package com.vitorpamplona.quartz.nip01Core.relay.client.stats
import androidx.collection.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class RelayStat(
var receivedBytes: Int = 0,
var sentBytes: Int = 0,
@@ -31,12 +33,11 @@ class RelayStat(
var pingInMs: Int = 0,
var compression: Boolean = false,
) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
val messages = LruCache<IRelayDebugMessage, IRelayDebugMessage>(100)
fun newNotice(notice: String?) {
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.NOTICE,
NoticeDebugMessage(
message = notice ?: "No error message provided",
)
@@ -47,8 +48,7 @@ class RelayStat(
errorCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.ERROR,
ErrorDebugMessage(
message = error ?: "No error message provided",
)
@@ -63,27 +63,35 @@ class RelayStat(
sentBytes += bytesUsedInMemory
}
fun newSpam(spamDescriptor: String) {
fun newSpam(
link1: String,
link2: String,
) {
spamCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.SPAM,
message = spamDescriptor,
)
val debugMessage = SpamDebugMessage(link1, link2)
messages.put(debugMessage, debugMessage)
}
}
class RelayDebugMessage(
val type: RelayDebugMessageType,
val message: String,
val time: Long = TimeUtils.now(),
)
enum class RelayDebugMessageType {
SPAM,
NOTICE,
ERROR,
@Stable
sealed interface IRelayDebugMessage {
val time: Long
}
class SpamDebugMessage(
val link1: String,
val link2: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
class NoticeDebugMessage(
val message: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
class ErrorDebugMessage(
val message: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.utils.Log
@@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.utils.Log
* - authors: Optional list of author public keys (must be 64 characters).
* - kinds: Optional list of event kinds to include.
* - tags: Optional map of tag names to values arrays (common tags like 'p', 'e', 'a' are validated).
* - tagsAll: Optional map of tag names to values arrays that must all match (common tags like 'p', 'e', 'a' are validated).
* - since: Optional timestamp for filtering events with publication time this value.
* - until: Optional timestamp for filtering events with publication time this value.
* - limit: Optional maximum number of events to request.
@@ -44,10 +47,11 @@ import com.vitorpamplona.quartz.utils.Log
* follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs.
*/
class Filter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
val kinds: List<Kind>? = null,
val tags: Map<String, List<String>>? = null,
val tagsAll: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
@@ -55,31 +59,33 @@ class Filter(
) : OptimizedSerializable {
fun toJson() = OptimizedJsonMapper.toJson(this)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, tagsAll, since, until)
fun copy(
ids: List<String>? = this.ids,
authors: List<String>? = this.authors,
kinds: List<Int>? = this.kinds,
tags: Map<String, List<String>>? = this.tags,
tagsAll: Map<String, List<String>>? = this.tagsAll,
since: Long? = this.since,
until: Long? = this.until,
limit: Int? = this.limit,
search: String? = this.search,
) = Filter(ids, authors, kinds, tags, since, until, limit, search)
) = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search)
/**
* Returns true if this filter contains any non-null and non-empty criteria.
* Returns true if this filter doesn't filter for anything.
*/
fun isFilledFilter() =
(ids != null && ids.isNotEmpty()) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) ||
(since != null) ||
(until != null) ||
(limit != null) ||
(search != null && search.isNotEmpty())
fun isEmpty() =
(ids == null || ids.isEmpty()) &&
(authors == null || authors.isEmpty()) &&
(kinds == null || kinds.isEmpty()) &&
(tags == null || tags.isEmpty() && tags.values.all { it.isNotEmpty() }) &&
(tagsAll == null || tagsAll.isEmpty() && tagsAll.values.all { it.isNotEmpty() }) &&
(since == null) &&
(until == null) &&
(limit == null) &&
(search == null || search.isEmpty())
init {
ids?.forEach {
@@ -89,14 +95,27 @@ class Filter(
if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}")
}
// tests common tags.
tags?.get("p")?.forEach {
if (tags != null) {
tags["p"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
}
tags?.get("e")?.forEach {
tags["e"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tags?.get("a")?.forEach {
tags["a"]?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
}
if (tagsAll != null) {
tagsAll["p"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
}
tagsAll["e"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tagsAll["a"]?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
}
}
}
@@ -29,6 +29,7 @@ object FilterMatcher {
authors: List<String>? = null,
kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null,
tagsAll: Map<String, List<String>>? = null,
since: Long? = null,
until: Long? = null,
): Boolean {
@@ -36,7 +37,23 @@ object FilterMatcher {
if (kinds?.contains(event.kind) == false) return false
if (authors?.contains(event.pubKey) == false) return false
tags?.forEach { tag ->
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
val valueSet = tag.value.toSet()
// AND between keys, OR between values
if (!event.tags.any { it.size > 1 && it[0] == tag.key && it[1] in valueSet }) return false
}
tagsAll?.forEach { tag ->
val eventTagValueSet =
event.tags.mapNotNullTo(mutableSetOf()) {
if (it.size > 1 && it[0] == tag.key) {
it[1]
} else {
null
}
}
// AND between keys, AND between values
for (tagValue in tag.value) {
if (tagValue !in eventTagValueSet) return false
}
}
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import androidx.compose.runtime.Stable
@Stable
data class NormalizedRelayUrl(
val url: String,
) : Comparable<NormalizedRelayUrl> {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.text
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
@@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
object FlexibleIntListSerializer : KSerializer<List<Int>?> {
private val listSerializer = ListSerializer(Int.serializer())
object FlexibleIntListSerializer : KSerializer<List<String>?> {
private val listSerializer = ListSerializer(String.serializer())
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun deserialize(decoder: Decoder): List<Int>? {
override fun deserialize(decoder: Decoder): List<String>? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
return when (val element = decoder.decodeJsonElement()) {
@@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
is JsonArray -> {
element.mapNotNull { arrayElement ->
try {
arrayElement.jsonPrimitive.int
arrayElement.jsonPrimitive.text
} catch (e: Exception) {
// Skip elements that aren't valid integers (strings, booleans, floats, etc.)
Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e)
@@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
// Handle single integer format (malformed but found in the wild): 1
is JsonPrimitive if !element.isString -> {
try {
listOf(element.int)
listOf(element.text)
} catch (e: Exception) {
// Can't parse as integer (e.g., float, boolean), treat as missing data
Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e)
@@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(
encoder: Encoder,
value: List<Int>?,
value: List<String>?,
) {
if (value == null) {
encoder.encodeNull()
@@ -21,19 +21,22 @@
package com.vitorpamplona.quartz.nip11RelayInfo
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
@Stable
@Serializable
class Nip11RelayInformation(
val id: String? = null,
val name: String? = null,
val description: String? = null,
val icon: String? = null,
val pubkey: String? = null,
val pubkey: HexKey? = null,
val self: HexKey? = null,
val contact: String? = null,
@Serializable(with = FlexibleIntListSerializer::class)
val supported_nips: List<Int>? = null,
val supported_nips: List<String>? = null,
val supported_nip_extensions: List<String>? = null,
val software: String? = null,
val version: String? = null,
@@ -42,15 +45,17 @@ class Nip11RelayInformation(
val language_tags: List<String>? = null,
val tags: List<String>? = null,
val posting_policy: String? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val payments_url: String? = null,
val retention: List<RelayInformationRetentionData>? = null,
val fees: RelayInformationFees? = null,
val nip50: List<String>? = null,
val supported_grasps: List<String>? = null,
) {
companion object {
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
}
}
@Stable
@Serializable
@@ -61,6 +66,7 @@ class RelayInformationFee(
val kinds: List<Int>? = null,
)
@Stable
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
@@ -68,12 +74,14 @@ class RelayInformationFees(
val publication: List<RelayInformationFee>? = null,
)
@Stable
@Serializable
class RelayInformationLimitation(
val max_message_length: Int? = null,
val max_subscriptions: Int? = null,
val max_filters: Int? = null,
val max_limit: Int? = null,
val default_limit: Int? = null,
val max_subid_length: Int? = null,
val min_prefix: Int? = null,
val max_event_tags: Int? = null,
@@ -86,9 +94,11 @@ class RelayInformationLimitation(
val created_at_upper_limit: Int? = null,
)
@Stable
@Serializable
class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
)
}
@@ -148,6 +148,21 @@ class BookmarkListEvent(
)
}
suspend fun remove(
earlierVersion: BookmarkListEvent,
bookmarkIdTag: BookmarkIdTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()),
tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.quartz.nip94FileMetadata.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import kotlinx.serialization.Serializable
@Serializable
@Stable
class DimensionTag(
val width: Int,
val height: Int,
@@ -0,0 +1,149 @@
/**
* 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.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilterMatcherTest {
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() {
assertTrue(note, Filter(ids = listOf(id)))
assertTrue(note, Filter(ids = listOf(id, rootETag)))
assertFalse(note, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() {
assertTrue(note, Filter(authors = listOf(pubkey)))
assertTrue(note, Filter(authors = listOf(pubkey, rootETag)))
assertFalse(note, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() {
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() {
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() {
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() {
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() {
assertTrue(note, Filter(kinds = listOf(kind)))
assertTrue(note, Filter(kinds = listOf(kind, 1221)))
assertFalse(note, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() {
assertTrue(note, Filter(since = createdAt))
assertTrue(note, Filter(since = createdAt - 1))
assertFalse(note, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() {
assertTrue(note, Filter(until = createdAt))
assertTrue(note, Filter(until = createdAt + 1))
assertFalse(note, Filter(until = createdAt - 1))
}
fun assertTrue(
event: Event,
filter: Filter,
) = assertTrue(filter.match(event))
fun assertFalse(
event: Event,
filter: Filter,
) = assertFalse(filter.match(event))
}
@@ -74,7 +74,7 @@ class Nip11RelayInformationTest {
assertEquals("purplepag.es", info.name)
assertEquals("Nostr's Purple Pages", info.description)
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
assertEquals(listOf(1, 2, 9, 11), info.supported_nips)
assertEquals(listOf("1", "2", "9", "11"), info.supported_nips)
assertEquals("1.0.4", info.version)
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
}
@@ -88,7 +88,7 @@ class Nip11RelayInformationTest {
assertEquals("Nostr's Purple Pages", info.description)
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
// Single integer should be converted to a list with one element
assertEquals(listOf(1), info.supported_nips)
assertEquals(listOf("1"), info.supported_nips)
assertEquals("1.0.4", info.version)
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
}
@@ -132,7 +132,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// Should skip invalid elements and keep only valid integers
assertEquals(listOf(1, 3, 11), info.supported_nips)
assertEquals(listOf("1", "invalid", "3", "4.5", "true", "11"), info.supported_nips)
}
@Test
@@ -144,7 +144,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// All elements invalid, should result in empty list
assertEquals(emptyList(), info.supported_nips)
assertEquals(listOf("one", "two", "three"), info.supported_nips)
}
@Test
@@ -156,7 +156,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// Cannot parse float as integer, should be null
assertNull(info.supported_nips)
assertEquals(listOf("1.5"), info.supported_nips)
}
@Test
@@ -168,7 +168,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// Cannot parse boolean as integer, should be null
assertNull(info.supported_nips)
assertEquals(listOf("true"), info.supported_nips)
}
@Test
@@ -204,6 +204,6 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
assertEquals((1..100).toList(), info.supported_nips)
assertEquals((1..100).map { it.toString() }, info.supported_nips)
}
}
@@ -106,9 +106,9 @@ class NIP19ParserTest {
val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug")
assertNotNull(actual)
assertTrue(actual?.entity is NProfile)
assertTrue(actual.entity is NProfile)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex)
assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first())
assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay.first())
}
@Test()
@@ -267,7 +267,7 @@ class NIP19ParserTest {
"31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far",
result.aTag(),
)
assertEquals(true, result.relay?.isEmpty())
assertEquals(true, result.relay.isEmpty())
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author)
assertEquals(31337, result.kind)
}
@@ -285,7 +285,7 @@ class NIP19ParserTest {
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509",
result.aTag(),
)
assertEquals(true, result.relay?.isEmpty())
assertEquals(true, result.relay.isEmpty())
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
assertEquals(30023, result.kind)
}
@@ -303,7 +303,7 @@ class NIP19ParserTest {
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418",
result.aTag(),
)
assertEquals(true, result.relay?.isEmpty())
assertEquals(true, result.relay.isEmpty())
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
assertEquals(30023, result.kind)
}
@@ -356,7 +356,7 @@ class NIP19ParserTest {
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex)
assertEquals(
NormalizedRelayUrl("wss://nostr.mom/"),
result.relay?.get(0),
result.relay[0],
)
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author)
assertEquals(1, result.kind)
@@ -403,7 +403,7 @@ class NIP19ParserTest {
assertNotNull(result)
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex)
assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url)
assertEquals("wss://relay.damus.io/", result.relay[0].url)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author)
assertEquals(1, result.kind)
}
@@ -24,7 +24,6 @@ import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import platform.Security.SecRandomCopyBytes
import platform.Security.kSecRandomDefault
import kotlin.random.Random.Default.nextBytes
actual class SecureRandom {
actual fun nextInt(): Int {
@@ -63,6 +63,10 @@ class EventDeserializer : StdDeserializer<Event>(Event::class.java) {
}
}
if (pubKey.isEmpty()) {
throw IllegalArgumentException("Event not found")
}
return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig)
}
}
@@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
import kotlinx.serialization.json.JsonNull.content
import java.io.InputStream
class JacksonMapper {
@@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
class ManualFilterDeserializer {
companion object {
fun fromJson(jsonObject: ObjectNode): Filter {
val tags = mutableListOf<String>()
val tagsIn = mutableListOf<String>()
jsonObject.fieldNames().forEach {
if (it.startsWith("#")) {
tags.add(it.substring(1))
tagsIn.add(it.substring(1))
}
}
val tagsAll = mutableListOf<String>()
jsonObject.fieldNames().forEach {
if (it.startsWith("&")) {
tagsAll.add(it.substring(1))
}
}
@@ -49,7 +55,8 @@ class ManualFilterDeserializer {
ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() },
tags = tags.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
tags = tagsIn.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
tagsAll = tagsAll.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
since = jsonObject.get("since").asLongOrNull(),
until = jsonObject.get("until").asLongOrNull(),
limit = jsonObject.get("limit").asIntOrNull(),
@@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer<Filter>(Filter::class.java) {
}
}
filter.tagsAll?.run {
entries.forEach { kv ->
gen.writeArrayFieldStart("&${kv.key}")
for (i in kv.value.indices) {
gen.writeString(kv.value[i])
}
gen.writeEndArray()
}
}
filter.since?.run { gen.writeNumberField("since", this) }
filter.until?.run { gen.writeNumberField("until", this) }
filter.limit?.run { gen.writeNumberField("limit", this) }