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) val relayStats = RelayStats(client)
// Logs debug messages when needed // 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 relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null
@@ -248,10 +248,8 @@ object LocalPreferences {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath) val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach { prefsDir.list()?.forEach {
if (it.contains(npub)) { if (it.contains(npub) && !File(prefsDir, it).delete()) {
if (!File(prefsDir, it).delete()) { Log.w("LocalPreferences", "Failed to delete preference file: $it")
Log.w("LocalPreferences", "Failed to delete preference file: $it")
}
} }
} }
} }
@@ -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( suspend fun createAuthEvent(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
challenge: String, challenge: String,
@@ -95,7 +95,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) { if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats Amethyst.instance.relayStats
.get(relay) .get(relay)
.newSpam("$link1 $link2") .newSpam(link1, link2)
} }
flowSpam.tryEmit(AntiSpamState(this)) flowSpam.tryEmit(AntiSpamState(this))
@@ -123,7 +123,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) { if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats Amethyst.instance.relayStats
.get(relay) .get(relay)
.newSpam("$link1 $link2") .newSpam(link1, link2)
} }
flowSpam.tryEmit(AntiSpamState(this)) flowSpam.tryEmit(AntiSpamState(this))
@@ -1106,7 +1106,6 @@ object LocalCache : ILocalCache {
val new = consumeBaseReplaceable(event, relay, wasVerified) val new = consumeBaseReplaceable(event, relay, wasVerified)
if (new) { if (new) {
println("AABBCC New ContactCard about ${event.aboutUser()}")
val about = checkGetOrCreateUser(event.aboutUser()) ?: return new val about = checkGetOrCreateUser(event.aboutUser()) ?: return new
about.cards().addCard(note) about.cards().addCard(note)
} }
@@ -2890,7 +2889,7 @@ object LocalCache : ILocalCache {
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e 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 false
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.model.nip51Lists package com.vitorpamplona.amethyst.model.nip51Lists
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@Stable
class BookmarkListState( class BookmarkListState(
val signer: NostrSigner, val signer: NostrSigner,
val cache: LocalCache, val cache: LocalCache,
@@ -274,4 +276,26 @@ class BookmarkListState(
null 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 package com.vitorpamplona.amethyst.service.logging
import android.os.Build import android.os.Build
import android.os.Looper
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy import android.os.StrictMode.VmPolicy
@@ -59,8 +58,8 @@ class Logging {
}.penaltyLog() }.penaltyLog()
.build(), .build(),
) )
Looper.getMainLooper().setMessageLogging(LogMonitor()) // Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start() // ChoreographerHelper.start()
// Enable recomposition tracking ONLY in debug builds // Enable recomposition tracking ONLY in debug builds
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
@@ -74,64 +74,23 @@ fun VoiceMessagePreview(
var progress by remember { mutableFloatStateOf(0f) } var progress by remember { mutableFloatStateOf(0f) }
var mediaPlayer by remember { mutableStateOf<MediaPlayer?>(null) } var mediaPlayer by remember { mutableStateOf<MediaPlayer?>(null) }
// Initialize MediaPlayer ManageMediaPlayer(
DisposableEffect(voiceMetadata.url, localFile) { voiceMetadata = voiceMetadata,
val player = createMediaPlayer(voiceMetadata.url, localFile) localFile = localFile,
player?.setOnCompletionListener { onCompletion = {
isPlaying = false isPlaying = false
progress = 0f progress = 0f
} },
mediaPlayer = player onPlayerChanged = { mediaPlayer = it },
onRelease = { isPlaying = false },
)
onDispose { TrackPlaybackProgress(
// Stop playback and clean up mediaPlayer = mediaPlayer,
try { isPlaying = isPlaying,
player?.stop() onProgressUpdate = { progress = it },
} catch (e: IllegalStateException) { onInvalidState = { isPlaying = false },
// 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)
}
}
}
Box( Box(
modifier = modifier =
@@ -150,27 +109,13 @@ fun VoiceMessagePreview(
// Play/Pause Button // Play/Pause Button
IconButton( IconButton(
onClick = { onClick = {
val player = mediaPlayer handlePlayPauseClick(
if (player != null) { mediaPlayer = mediaPlayer,
try { isPlaying = isPlaying,
if (isPlaying) { progress = progress,
player.pause() onProgressReset = { progress = 0f },
isPlaying = false onPlayingChanged = { isPlaying = it },
} 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
}
}
}, },
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
) { ) {
@@ -194,24 +139,11 @@ fun VoiceMessagePreview(
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
onProgressChange = { newProgress -> onProgressChange = { newProgress ->
// Validate incoming progress value handleWaveformScrub(
if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) { newProgress = newProgress,
val player = mediaPlayer mediaPlayer = mediaPlayer,
if (player != null) { onProgressChanged = { progress = it },
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)
}
}
}
}, },
) )
@@ -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( private fun createMediaPlayer(
url: String, url: String,
localFile: File?, localFile: File?,
@@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.myapplication.DisappearingBottomBar import com.vitorpamplona.myapplication.DisappearingBottomBar
import com.vitorpamplona.myapplication.DisappearingFloatingButton import com.vitorpamplona.myapplication.DisappearingFloatingButton
import com.vitorpamplona.myapplication.DisappearingTopBar
import com.vitorpamplona.myapplication.enterAlwaysScrollBehavior
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.myapplication package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.AnimationState
@@ -58,7 +58,7 @@ fun DisappearingTopBar(
scrollBehavior: CustomEnterAlwaysScrollBehavior, scrollBehavior: CustomEnterAlwaysScrollBehavior,
content: @Composable (ColumnScope.() -> Unit), content: @Composable (ColumnScope.() -> Unit),
) { ) {
ResetDisappearingOnResume(scrollBehavior) // ResetDisappearingOnResume(scrollBehavior)
// Set up support for resizing the top app bar when vertically dragging the bar itself. // Set up support for resizing the top app bar when vertically dragging the bar itself.
val appBarDragModifier = 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.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.display.BookmarkGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen 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.list.metadata.BookmarkGroupMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen 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.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.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen 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.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete 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.CloudUpload
import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.Drafts
@@ -458,14 +457,6 @@ fun ListContent(
NavigationRow( NavigationRow(
title = R.string.bookmarks, 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, icon = Icons.Outlined.CollectionsBookmark,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
nav = nav, nav = nav,
@@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String ->
"https://njump.to/$nip19BechAddress" "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 -> val externalLinkForNote = { note: Note ->
if (note is AddressableNote) { if (note is AddressableNote) {
if (note.event?.bountyBaseReward() != null) { 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 { fun showAmountIntegerWithZero(amount: BigDecimal?): String {
if (amount == null) return "0" if (amount == null) return "0"
if (amount.abs() < BigDecimal(0.01)) 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 * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column 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.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -93,7 +93,7 @@ private fun RenderBookmarkScreen(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { topBar = {
Column { Column {
TopBarWithBackButton(stringRes(id = R.string.bookmarks), nav::popBack) TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack)
TabRow( TabRow(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.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.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.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.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -42,7 +42,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -59,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -118,7 +118,7 @@ fun BookmarkGroupScreenView(
Scaffold( Scaffold(
topBar = { topBar = {
Column { Column {
TopAppBar( ShorterTopAppBar(
title = { title = {
TitleAndDescription(bookmarkGroupViewModel) TitleAndDescription(bookmarkGroupViewModel)
}, },
@@ -20,34 +20,43 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding 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 com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun ListOfBookmarkGroupsFeedView( fun ListOfBookmarkGroupsFeedView(
defaultBookmarks: BookmarkListState,
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>, groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
onOpenItem: (String, BookmarkType) -> Unit, onOpenItem: (String, BookmarkType) -> Unit,
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -56,40 +65,73 @@ fun ListOfBookmarkGroupsFeedView(
) { ) {
val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle() val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle()
if (bookmarkGroupFeedState.isEmpty()) { LazyColumn(
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) state = rememberLazyListState(),
} else { contentPadding = FeedPadding,
LazyColumn( ) {
state = rememberLazyListState(), item {
contentPadding = FeedPadding, DefaultBookmarkList(defaultBookmarks, openDefaultBookmarks)
) { HorizontalDivider(thickness = DividerThickness)
itemsIndexed( }
bookmarkGroupFeedState,
key = { _: Int, item: LabeledBookmarkList -> item.identifier }, itemsIndexed(
) { _, groupItem -> bookmarkGroupFeedState,
BookmarkGroupItem( key = { _: Int, item: LabeledBookmarkList -> item.identifier },
modifier = Modifier.fillMaxSize().animateItem(), ) { _, groupItem ->
bookmarkList = groupItem, BookmarkGroupItem(
onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, modifier = Modifier.fillMaxSize().animateItem(),
onRename = { onRenameItem(groupItem) }, bookmarkList = groupItem,
onDescriptionChange = { onItemDescriptionChange(groupItem) }, onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) },
onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, onRename = { onRenameItem(groupItem) },
onDelete = { onDeleteItem(groupItem) }, onDescriptionChange = { onItemDescriptionChange(groupItem) },
) onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) },
HorizontalDivider(thickness = DividerThickness) onDelete = { onDeleteItem(groupItem) },
} )
HorizontalDivider(thickness = DividerThickness)
} }
} }
} }
@Composable @Composable
fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { fun DefaultBookmarkList(
Column( defaultBookmarks: BookmarkListState,
Modifier.fillMaxSize().padding(horizontal = Size40dp), openDefaultBookmarks: () -> Unit,
horizontalAlignment = Alignment.CenterHorizontally, ) {
verticalArrangement = Arrangement.Center, val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle()
) {
Text(message) ListItem(
Spacer(modifier = StdVertSpacer) 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -49,7 +50,9 @@ fun ListOfBookmarkGroupsScreen(
nav: INav, nav: INav,
) { ) {
ListOfBookmarkGroupsFeed( ListOfBookmarkGroupsFeed(
defaultBookmarks = accountViewModel.account.bookmarkState,
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
openBookmarkGroup = { identifier, bookmarkType -> openBookmarkGroup = { identifier, bookmarkType ->
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
@@ -84,7 +87,9 @@ fun ListOfBookmarkGroupsScreen(
@Composable @Composable
fun ListOfBookmarkGroupsFeed( fun ListOfBookmarkGroupsFeed(
defaultBookmarks: BookmarkListState,
listSource: StateFlow<List<LabeledBookmarkList>>, listSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
addBookmarkGroup: () -> Unit, addBookmarkGroup: () -> Unit,
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -109,7 +114,9 @@ fun ListOfBookmarkGroupsFeed(
).fillMaxHeight(), ).fillMaxHeight(),
) { ) {
ListOfBookmarkGroupsFeedView( ListOfBookmarkGroupsFeedView(
defaultBookmarks = defaultBookmarks,
groupListFeedSource = listSource, groupListFeedSource = listSource,
openDefaultBookmarks = openDefaultBookmarks,
onOpenItem = openBookmarkGroup, onOpenItem = openBookmarkGroup,
onRenameItem = renameBookmarkGroup, onRenameItem = renameBookmarkGroup,
onItemDescriptionChange = changeBookmarkGroupDescription, 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.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.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.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -75,8 +74,6 @@ private fun ListManagementView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -95,48 +92,86 @@ private fun ListManagementView(
).consumeWindowInsets(contentPadding) ).consumeWindowInsets(contentPadding)
.imePadding(), .imePadding(),
) { ) {
if (bookmarkGroups.isEmpty()) { ListManagementViewBody(note, accountViewModel, nav)
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) }
} else { }
LazyColumn( }
state = rememberLazyListState(),
modifier = Modifier.fillMaxWidth(), @Composable
) { private fun ListManagementViewBody(
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> note: AddressableNote,
val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } accountViewModel: AccountViewModel,
val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } nav: INav,
BookmarkGroupManagementItem( ) {
modifier = Modifier.fillMaxWidth().animateItem(), val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
listTitle = bookmarkList.title, .collectAsStateWithLifecycle()
isPublicMemberBookmark = maybePublicBookmark != null,
isPrivateMemberBookmark = maybePrivateBookmark != null, val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks
totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, .collectAsStateWithLifecycle()
totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size,
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, LazyColumn(
onAddBookmarkToGroup = { shouldBePrivate -> state = rememberLazyListState(),
accountViewModel.launchSigner { modifier = Modifier.fillMaxWidth(),
accountViewModel.account.labeledBookmarkLists.addBookmarkToList( ) {
bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), item {
bookmarkListIdentifier = bookmarkList.identifier, val maybePublicBookmark = defaultBookmarks.public.contains(note)
isBookmarkPrivate = shouldBePrivate, val maybePrivateBookmark = defaultBookmarks.private.contains(note)
account = accountViewModel.account,
) BookmarkGroupManagementItem(
} modifier = Modifier.fillMaxWidth().animateItem(),
}, listTitle = stringRes(R.string.bookmarks_title),
onRemoveBookmarkFromGroup = { isPublicMemberBookmark = maybePublicBookmark,
accountViewModel.launchSigner { isPrivateMemberBookmark = maybePrivateBookmark,
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size,
bookmark = AddressBookmark(address = note.address), totalArticleBookmarkSize = 0,
bookmarkListIdentifier = bookmarkList.identifier, onClick = {
isBookmarkPrivate = maybePrivateBookmark != null, nav.nav(Route.Bookmarks)
account = accountViewModel.account, },
) 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 }
BookmarkGroupManagementItem(
modifier = Modifier.fillMaxWidth().animateItem(),
listTitle = bookmarkList.title,
isPublicMemberBookmark = maybePublicBookmark != null,
isPrivateMemberBookmark = maybePrivateBookmark != null,
totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size,
totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size,
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) },
onAddBookmarkToGroup = { shouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.labeledBookmarkLists.addBookmarkToList(
bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()),
bookmarkListIdentifier = bookmarkList.identifier,
isBookmarkPrivate = shouldBePrivate,
account = accountViewModel.account,
)
}
},
onRemoveBookmarkFromGroup = {
accountViewModel.launchSigner {
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList(
bookmark = AddressBookmark(address = note.address),
bookmarkListIdentifier = bookmarkList.identifier,
isBookmarkPrivate = maybePrivateBookmark != null,
account = accountViewModel.account,
)
}
},
)
} }
} }
} }
@@ -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.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.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.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
@@ -74,8 +73,6 @@ private fun ListManagementView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -94,48 +91,86 @@ private fun ListManagementView(
).consumeWindowInsets(contentPadding) ).consumeWindowInsets(contentPadding)
.imePadding(), .imePadding(),
) { ) {
if (bookmarkGroups.isEmpty()) { ListManagementViewBody(note, accountViewModel, nav)
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) }
} else { }
LazyColumn( }
state = rememberLazyListState(),
modifier = Modifier.fillMaxWidth(), @Composable
) { private fun ListManagementViewBody(
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> note: Note,
val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } accountViewModel: AccountViewModel,
val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } nav: INav,
BookmarkGroupManagementItem( ) {
modifier = Modifier.fillMaxWidth().animateItem(), val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
listTitle = bookmarkList.title, .collectAsStateWithLifecycle()
isPublicMemberBookmark = maybePublicBookmark != null,
isPrivateMemberBookmark = maybePrivateBookmark != null, val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks
totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, .collectAsStateWithLifecycle()
totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size,
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, LazyColumn(
onAddBookmarkToGroup = { shouldBePrivate -> state = rememberLazyListState(),
accountViewModel.launchSigner { modifier = Modifier.fillMaxWidth(),
accountViewModel.account.labeledBookmarkLists.addBookmarkToList( ) {
bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), item {
bookmarkListIdentifier = bookmarkList.identifier, val maybePublicBookmark = defaultBookmarks.public.contains(note)
isBookmarkPrivate = shouldBePrivate, val maybePrivateBookmark = defaultBookmarks.private.contains(note)
account = accountViewModel.account,
) BookmarkGroupManagementItem(
} modifier = Modifier.fillMaxWidth().animateItem(),
}, listTitle = stringRes(R.string.bookmarks_title),
onRemoveBookmarkFromGroup = { isPublicMemberBookmark = maybePublicBookmark,
accountViewModel.launchSigner { isPrivateMemberBookmark = maybePrivateBookmark,
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size,
bookmark = EventBookmark(eventId = note.idHex), totalArticleBookmarkSize = 0,
bookmarkListIdentifier = bookmarkList.identifier, onClick = {
isBookmarkPrivate = maybePrivateBookmark != null, nav.nav(Route.Bookmarks)
account = accountViewModel.account, },
) 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 }
BookmarkGroupManagementItem(
modifier = Modifier.fillMaxWidth().animateItem(),
listTitle = bookmarkList.title,
isPublicMemberBookmark = maybePublicBookmark != null,
isPrivateMemberBookmark = maybePrivateBookmark != null,
totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size,
totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size,
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) },
onAddBookmarkToGroup = { shouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.labeledBookmarkLists.addBookmarkToList(
bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex),
bookmarkListIdentifier = bookmarkList.identifier,
isBookmarkPrivate = shouldBePrivate,
account = accountViewModel.account,
)
}
},
onRemoveBookmarkFromGroup = {
accountViewModel.launchSigner {
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList(
bookmark = EventBookmark(eventId = note.idHex),
bookmarkListIdentifier = bookmarkList.identifier,
isBookmarkPrivate = maybePrivateBookmark != null,
account = accountViewModel.account,
)
}
},
)
} }
} }
} }
@@ -972,7 +972,6 @@ open class ShortNotePostViewModel :
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) 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 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 -> val uploadVoiceExceptionMessage: (String) -> String = { detail ->
stringRes(appContext, R.string.upload_error_voice_message_exception, detail) stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
} }
@@ -36,5 +36,5 @@ fun BookmarkTabHeader(
) { ) {
val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel) 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 = val DarkChannelNotePictureModifier =
Modifier Modifier
.size(30.dp) .size(20.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.border(2.dp, DarkColorPalette.background, CircleShape) .border(2.dp, DarkColorPalette.background, CircleShape)
val LightChannelNotePictureModifier = val LightChannelNotePictureModifier =
Modifier Modifier
.size(30.dp) .size(20.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.border(2.dp, LightColorPalette.background, 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">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_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="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_description">Rögzítés</string>
<string name="recording_indicator_with_time">%1$s rögzítése</string> <string name="recording_indicator_with_time">%1$s rögzítése</string>
<string name="uploading">Feltöltés…</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="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_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_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_tor1">Módosítás</string>
<string name="connect_via_tor2">Tor beállítások</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_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="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> <string name="yes">Igen</string>
@@ -158,6 +158,7 @@
<string name="record_a_message">Nagraj wiadomość</string> <string name="record_a_message">Nagraj wiadomość</string>
<string name="record_a_message_title">Nagrywanie wiadomości</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="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_description">Nagrywanie</string>
<string name="recording_indicator_with_time">Nagrywanie %1$s</string> <string name="recording_indicator_with_time">Nagrywanie %1$s</string>
<string name="uploading">Wgrywanie…</string> <string name="uploading">Wgrywanie…</string>
@@ -356,6 +357,8 @@
<string name="block_only">Zablokuj</string> <string name="block_only">Zablokuj</string>
<string name="manual_zaps">Ręczny podział zapów</string> <string name="manual_zaps">Ręczny podział zapów</string>
<string name="bookmarks">Zakładki</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="drafts">Projekty</string>
<string name="private_bookmarks">Prywatne Zakładki</string> <string name="private_bookmarks">Prywatne Zakładki</string>
<string name="public_bookmarks">Publiczne 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="illegal_behavior">Noqonuniy xatti-harakat</string>
<string name="other">Boshqa sabab</string> <string name="other">Boshqa sabab</string>
<string name="harassment">Taqib</string> <string name="harassment">Taqib</string>
<string name="violence">Zo\'ravon</string>
<string name="unknown">Noma\'lum</string> <string name="unknown">Noma\'lum</string>
<string name="relay_icon">Relay belgisi</string> <string name="relay_icon">Relay belgisi</string>
<string name="unknown_author">Nomaʼlum muallif</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_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_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_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> </resources>
@@ -158,6 +158,7 @@
<string name="record_a_message">录制消息</string> <string name="record_a_message">录制消息</string>
<string name="record_a_message_title">录制消息</string> <string name="record_a_message_title">录制消息</string>
<string name="record_a_message_description">点按以录制消息</string> <string name="record_a_message_description">点按以录制消息</string>
<string name="re_record">重新录制</string>
<string name="recording_indicator_description">正在录制</string> <string name="recording_indicator_description">正在录制</string>
<string name="recording_indicator_with_time">正在录制 %1$s</string> <string name="recording_indicator_with_time">正在录制 %1$s</string>
<string name="uploading">上传中…</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="could_not_decrypt_the_message">Could not decrypt the message</string>
<string name="group_picture">Group Picture</string> <string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</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">Spam</string>
<string name="spam_description">The number of spamming events coming from this relay</string> <string name="spam_description">The number of spamming events coming from this relay</string>
<string name="impersonation">Impersonation</string> <string name="impersonation">Impersonation</string>
@@ -133,6 +135,7 @@
<string name="relay_address">Relay Address</string> <string name="relay_address">Relay Address</string>
<string name="posts">Posts</string> <string name="posts">Posts</string>
<string name="bytes">Bytes</string> <string name="bytes">Bytes</string>
<string name="error">Error</string>
<string name="errors">Errors</string> <string name="errors">Errors</string>
<string name="errors_description">The number of connection errors in this session</string> <string name="errors_description">The number of connection errors in this session</string>
<string name="home_feed">Home Feed</string> <string name="home_feed">Home Feed</string>
@@ -391,6 +394,8 @@
<string name="manual_zaps">Manual Zap Splits</string> <string name="manual_zaps">Manual Zap Splits</string>
<string name="bookmarks">Bookmarks</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="drafts">Drafts</string>
<string name="private_bookmarks">Private Bookmarks</string> <string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public 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="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="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="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="version">Version</string>
<string name="software">Software</string> <string name="software">Software</string>
<string name="contact">Contact</string> <string name="contact">Contact</string>
<string name="supports">Supported NIPs</string> <string name="supports">Supported NIPs</string>
<string name="supported_grasps">Supported Grasps</string>
<string name="admission_fees">Admission Fees</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="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="limitations">Limitations</string>
<string name="countries">Countries</string> <string name="countries">Countries</string>
<string name="languages">Languages</string> <string name="languages">Languages</string>
<string name="tags">Tags</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="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="relay_error_messages">Errors and Notices from this Relay</string>
<string name="message_length">Message length</string> <string name="message_length">Message length</string>
<string name="subscriptions">Subscriptions</string> <string name="subscriptions">Subscriptions</string>
<string name="filters">Filters</string> <string name="filters">Filters</string>
<string name="subscription_id_length">Subscription id length</string> <string name="subscription_id_length">Subscription id length</string>
<string name="minimum_prefix">Minimum prefix</string> <string name="minimum_prefix">Min Prefix</string>
<string name="maximum_event_tags">Maximum event tags</string> <string name="maximum_event_tags">Max Event Tags</string>
<string name="content_length">Content length</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">Auth</string>
<string name="auth_required">Auth Required</string>
<string name="payment">Payment</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">Cashu Token</string>
<string name="cashu_redeem">Redeem</string> <string name="cashu_redeem">Redeem</string>
<string name="cashu_redeem_to_zap">Send to Zap Wallet</string> <string name="cashu_redeem_to_zap">Send to Zap Wallet</string>
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.commons.account package com.vitorpamplona.amethyst.commons.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.commons.util package com.vitorpamplona.amethyst.commons.util
import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale 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. * Formats a Unix timestamp as a date string.
* For recent dates (< 1 day), returns the provided today 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" translate = "17.0.3"
unifiedpush = "3.0.10" unifiedpush = "3.0.10"
urlDetector = "0.1.23" urlDetector = "0.1.23"
vico-charts = "2.3.6" vico-charts = "2.4.0"
zelory = "3.0.1" zelory = "3.0.1"
zoomable = "2.9.0" zoomable = "2.9.0"
zxing = "3.5.4" 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" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } 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" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" } mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }
@@ -20,9 +20,7 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -30,91 +28,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase import junit.framework.TestCase
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Before
import org.junit.Test import org.junit.Test
class AddressableTest { class AddressableTest : BaseDBTest() {
private lateinit var db: EventStore
val signer = NostrSignerSync() val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testReplacingAddressables() { fun testReplacingAddressables() =
val time = TimeUtils.now() forEachDB { db ->
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) val time = TimeUtils.now()
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version1)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
@Test
fun testBlockingOldAddressables() {
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))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1) db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) { db.assertQuery(version1, Filter(ids = listOf(version1.id)))
TestCase.assertTrue(e is SQLiteConstraintException) db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
} }
db.assertQuery(version3, Filter(ids = listOf(version3.id))) @Test
db.assertQuery(version3, addressableQuery) fun testBlockingOldAddressables() =
db.assertQuery(null, Filter(ids = listOf(version2.id))) forEachDB { db ->
db.assertQuery(null, Filter(ids = listOf(version1.id))) 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))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test @Test
fun testTriggersIndexUsage() { fun testTriggersIndexUsage() =
val explainer = forEachDB { db ->
db.store.explainQuery( val explainer =
db.store.explainQuery(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 30000 AND
event_headers.pubkey = 'aa' AND
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
""".trimIndent(),
)
assertEquals(
""" """
SELECT * FROM event_headers SELECT * FROM event_headers
WHERE WHERE
@@ -123,21 +121,9 @@ class AddressableTest {
event_headers.d_tag = 'test-tag' AND event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000 event_headers.kind >= 30000 AND event_headers.kind < 40000
SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
""".trimIndent(), """.trimIndent(),
explainer,
) )
}
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 30000 AND
event_headers.pubkey = 'aa' AND
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
""".trimIndent(),
explainer,
)
}
} }
@@ -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 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.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test import org.junit.Test
class BasicTest { class BasicTest : BaseDBTest() {
private lateinit var db: SQLiteEventStore
val signer = NostrSignerSync() val signer = NostrSignerSync()
companion object Companion { companion object {
val profile = val profile =
MetadataEvent( MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
@@ -82,168 +76,166 @@ class BasicTest {
) )
} }
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = SQLiteEventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testInsertDeleteEvent() { fun testInsertDeleteEvent() =
val note = signer.sign(TextNoteEvent.build("test1")) 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() {
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
db.insertEvent(note1)
db.assertQuery(note1, Filter())
db.insertEvent(note2)
db.assertQuery(listOf(note2, note1), Filter())
}
@Test
fun testLimitFilter() {
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.assertQuery(note1, Filter(limit = 1))
db.insertEvent(note2)
db.insertEvent(note3)
db.insertEvent(note4)
db.assertQuery(listOf(note4), Filter(limit = 1))
}
@Test
fun testPubkeyTag() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(
comment,
Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))),
)
}
@Test
fun testTagOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
}
@Test
fun testTagWithSinceOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1),
)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt),
)
db.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1),
)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt),
)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnlyEmitting() {
db.insertEvent(comment)
db.insertEvent(profile)
db.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
assertEquals(comment.toJson(), event.toJson())
} }
}
@Test @Test
fun hashCodeTest() { fun testEmptyFilter() =
val note1 = forEachDB { db ->
signer.sign( val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
TextNoteEvent.build("test1") { val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
hashtag("AaAa")
},
)
val note2 =
signer.sign(
TextNoteEvent.build("test2") {
hashtag("AaAa")
},
)
val note3 =
signer.sign(
TextNoteEvent.build("test3") {
hashtag("BBBB")
},
)
db.insertEvent(note1) db.store.insertEvent(note1)
db.insertEvent(note2)
db.insertEvent(note3)
val list = db.store.assertQuery(note1, Filter())
db.query<TextNoteEvent>(
Filter(
tags = mapOf("t" to listOf("AaAa")),
),
)
assertEquals(2, list.size) db.store.insertEvent(note2)
list.forEach {
assertTrue(it.isTaggedHash("AaAa")) db.store.assertQuery(listOf(note2, note1), Filter())
}
@Test
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.store.insertEvent(note1)
db.store.assertQuery(note1, Filter(limit = 1))
db.store.insertEvent(note2)
db.store.insertEvent(note3)
db.store.insertEvent(note4)
db.store.assertQuery(listOf(note4), Filter(limit = 1))
}
@Test
fun testPubkeyTag() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
comment,
Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))),
)
}
@Test
fun testTagOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
}
@Test
fun testTagWithSinceOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt),
)
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnlyEmitting() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
assertEquals(comment.toJson(), event.toJson())
}
}
@Test
fun hashCodeTest() =
forEachDB { db ->
val note1 =
signer.sign(
TextNoteEvent.build("test1") {
hashtag("AaAa")
},
)
val note2 =
signer.sign(
TextNoteEvent.build("test2") {
hashtag("AaAa")
},
)
val note3 =
signer.sign(
TextNoteEvent.build("test3") {
hashtag("BBBB")
},
)
db.store.insertEvent(note1)
db.store.insertEvent(note2)
db.store.insertEvent(note3)
val list =
db.query<TextNoteEvent>(
Filter(
tags = mapOf("t" to listOf("AaAa")),
),
)
assertEquals(2, list.size)
list.forEach {
assertTrue(it.isTaggedHash("AaAa"))
}
} }
}
} }
@@ -20,9 +20,7 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -33,380 +31,378 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase import junit.framework.TestCase
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test import org.junit.Test
class DeletionTest { class DeletionTest : BaseDBTest() {
private lateinit var db: EventStore
val signer = NostrSignerSync() val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testInsertDeleteEvent() { fun testInsertDeleteEvent() =
val note1 = signer.sign(TextNoteEvent.build("test1")) forEachDB { db ->
val note2 = signer.sign(TextNoteEvent.build("test2")) val note1 = signer.sign(TextNoteEvent.build("test1"))
val note3 = signer.sign(TextNoteEvent.build("test3")) val note2 = signer.sign(TextNoteEvent.build("test2"))
val note3 = signer.sign(TextNoteEvent.build("test3"))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1) db.insert(note1)
fail("Should not be able to insert a deleted event") db.insert(note2)
} catch (e: SQLiteConstraintException) { db.insert(note3)
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
} }
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
@Test @Test
fun testInsertDeleteEventOfAddressable() { fun testInsertDeleteEventOfAddressable() =
val time = TimeUtils.now() forEachDB { db ->
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) val time = TimeUtils.now()
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
db.insert(note1)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.insert(note2)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1) db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) { db.assertQuery(note1, Filter(ids = listOf(note1.id)))
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.insert(note2)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
} }
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
@Test @Test
fun testInsertDeleteEventOfAddressable2() { fun testInsertDeleteEventOfAddressable2() =
val time = TimeUtils.now() forEachDB { db ->
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) val time = TimeUtils.now()
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1) db.insert(note1)
fail("Should not be able to insert a deleted event") db.insert(note2)
} catch (e: SQLiteConstraintException) { db.insert(note3)
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
} }
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
@Test @Test
fun testInsertDeleteWrap() { fun testInsertDeleteWrap() =
val me = NostrSignerSync() forEachDB { db ->
val myFriend = NostrSignerSync() val me = NostrSignerSync()
val myFriend = NostrSignerSync()
val note1 = me.sign(TextNoteEvent.build("test1")) val note1 = me.sign(TextNoteEvent.build("test1"))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey) val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
db.insert(wrap1)
db.insert(wrap2)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(randomDeletionToWrap)
db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id)))
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val deletion = me.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1) db.insert(wrap1)
fail("Should not be able to insert a deleted event") db.insert(wrap2)
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(randomDeletionToWrap)
db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id)))
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val deletion = me.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
} }
db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) @Test
db.assertQuery(null, Filter(ids = listOf(wrap1.id))) fun testTriggersIndexUsage() =
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) forEachDB { db ->
} var sql = db.store.deletionModule.rejectDeletedEventsSQLTemplate()
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
| 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 @Test
fun testTriggersIndexUsage() { fun testDeleteById() =
val sql = forEachDB { db ->
""" val sql =
SELECT 1 FROM event_tags db.store.deletionModule
INNER JOIN event_headers .deleteSQL(
ON event_headers.row_id = event_tags.event_header_row_id pubkey = "key1",
WHERE idValues = listOf("ca29c211f", "ca29c211d"),
event_tags.tag_hash IN (3221122, 223322) AND addresses = emptyList(),
event_headers.kind = 5 AND hasher = TagNameValueHasher(0),
event_headers.created_at >= 1766686500 AND ).first()
event_headers.pubkey_owner_hash = 22332323
""".trimIndent()
val explainer = TestCase.assertEquals(
db.store.explainQuery(sql) """
DELETE FROM event_headers
TestCase.assertEquals( WHERE
""" id IN ("ca29c211f","ca29c211d") AND
${sql.replace("\n","\n ")} pubkey_owner_hash = "1573573083296714675"
SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) SEARCH event_headers USING INDEX event_headers_id (id=?)
SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
""".trimIndent(), SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
explainer, SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
) """.trimIndent(),
} db.store.explainQuery(sql.sql, sql.args),
)
}
@Test @Test
fun testDeleteById() { fun testDeleteAddressable() =
val sql = forEachDB { db ->
db.store.deletionModule val sql =
.deleteSQL( db.store.deletionModule
pubkey = "key1", .deleteSQL(
idValues = listOf("ca29c211f", "ca29c211d"), pubkey = "key1",
addresses = emptyList(), idValues = emptyList(),
hasher = TagNameValueHasher(0), addresses =
).first() listOf(
Address(30000, "key1", "a"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals( TestCase.assertEquals(
""" """
DELETE FROM event_headers DELETE FROM event_headers
WHERE WHERE (
id IN ("ca29c211f","ca29c211d") AND (kind = "30000" AND pubkey = "key1" AND d_tag = "a")
pubkey_owner_hash = "1573573083296714675" ) AND
SEARCH event_headers USING INDEX event_headers_id (id=?) kind >= 30000 AND kind < 40000
SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
""".trimIndent(), SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
db.store.explainQuery(sql.sql, sql.args), """.trimIndent(),
) db.store.explainQuery(sql.sql, sql.args),
} )
}
@Test @Test
fun testDeleteAddressable() { fun testDeleteAddressablesSingleKind() =
val sql = forEachDB { db ->
db.store.deletionModule val sql =
.deleteSQL( db.store.deletionModule
pubkey = "key1", .deleteSQL(
idValues = emptyList(), pubkey = "key1",
addresses = idValues = emptyList(),
listOf( addresses =
Address(30000, "key1", "a"), listOf(
), Address(30000, "key1", "a"),
hasher = TagNameValueHasher(0), Address(30000, "key1", "b"),
).first() Address(30000, "key1", "c"),
Address(30000, "key1", "d"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals( TestCase.assertEquals(
""" """
DELETE FROM event_headers DELETE FROM event_headers
WHERE ( WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag = "a") (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
) AND ) AND
kind >= 30000 AND kind < 40000 kind >= 30000 AND kind < 40000
SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(), """.trimIndent(),
db.store.explainQuery(sql.sql, sql.args), db.store.explainQuery(sql.sql, sql.args),
) )
} }
@Test @Test
fun testDeleteAddressablesSingleKind() { fun testDeleteAddressablesMultipleKinds() =
val sql = forEachDB { db ->
db.store.deletionModule val sql =
.deleteSQL( db.store.deletionModule
pubkey = "key1", .deleteSQL(
idValues = emptyList(), pubkey = "key1",
addresses = idValues = emptyList(),
listOf( addresses =
Address(30000, "key1", "a"), listOf(
Address(30000, "key1", "b"), Address(30000, "key1", "a"),
Address(30000, "key1", "c"), Address(30000, "key1", "b"),
Address(30000, "key1", "d"), Address(30101, "key1", "c"),
), Address(30101, "key1", "d"),
hasher = TagNameValueHasher(0), Address(30001, "key2", "e"),
).first() Address(30001, "key2", "f"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals( TestCase.assertEquals(
""" """
DELETE FROM event_headers DELETE FROM event_headers
WHERE ( WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b"))
) AND OR
kind >= 30000 AND kind < 40000 (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) ) AND
SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) kind >= 30000 AND kind < 40000
SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) MULTI-INDEX OR
SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) INDEX 1
""".trimIndent(), SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
db.store.explainQuery(sql.sql, sql.args), INDEX 2
) SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
} SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test @Test
fun testDeleteAddressablesMultipleKinds() { fun testDeleteReplaceables() =
val sql = forEachDB { db ->
db.store.deletionModule val sql =
.deleteSQL( db.store.deletionModule
pubkey = "key1", .deleteSQL(
idValues = emptyList(), pubkey = "key1",
addresses = idValues = emptyList(),
listOf( addresses =
Address(30000, "key1", "a"), listOf(
Address(30000, "key1", "b"), Address(10000, "key1", ""),
Address(30101, "key1", "c"), Address(10000, "key1", ""),
Address(30101, "key1", "d"), Address(10001, "key1", ""),
Address(30001, "key2", "e"), Address(10001, "key1", ""),
Address(30001, "key2", "f"), Address(10001, "key2", ""),
), Address(10001, "key2", ""),
hasher = TagNameValueHasher(0), ),
).first() hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals( TestCase.assertEquals(
""" """
DELETE FROM event_headers DELETE FROM event_headers
WHERE ( WHERE
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) kind IN ("10000","10001") AND
OR pubkey = "key1" AND
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
) AND SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
kind >= 30000 AND kind < 40000 SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
MULTI-INDEX OR SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
INDEX 1 SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(),
INDEX 2 db.store.explainQuery(sql.sql, sql.args),
SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) )
SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) }
SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteReplaceables() {
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(10000, "key1", ""),
Address(10000, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key2", ""),
Address(10001, "key2", ""),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
kind IN ("10000","10001") AND
pubkey = "key1" AND
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
} }
@@ -20,84 +20,69 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test import org.junit.Test
class ExpirationTest { class ExpirationTest : BaseDBTest() {
private lateinit var db: EventStore
val signer = NostrSignerSync() val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testDeletingExpiredEvents() { fun testDeletingExpiredEvents() =
val time = TimeUtils.now() forEachDB { db ->
val time = TimeUtils.now()
val noteSafe = val noteSafe =
signer.sign( signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) { TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 100) expiration(time + 100)
}, },
) )
db.insert(noteSafe) db.insert(noteSafe)
val noteToExpire = val noteToExpire =
signer.sign( signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) { TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 1) expiration(time + 1)
}, },
) )
db.insert(noteToExpire) db.insert(noteToExpire)
db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id)))
Thread.sleep(2000) Thread.sleep(2000)
db.deleteExpiredEvents() db.deleteExpiredEvents()
db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) db.assertQuery(null, Filter(ids = listOf(noteToExpire.id)))
db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id)))
} }
@Test @Test
fun testInsertingExpiredEvents() { fun testInsertingExpiredEvents() =
val time = TimeUtils.now() forEachDB { db ->
val time = TimeUtils.now()
val note1 =
signer.sign( val note1 =
TextNoteEvent.build("test1", createdAt = time - 12) { signer.sign(
expiration(time - 10) TextNoteEvent.build("test1", createdAt = time - 12) {
}, expiration(time - 10)
) },
)
try {
db.insert(note1) try {
fail("Should not be able to insert expired events") db.insert(note1)
} catch (e: Exception) { fail("Should not be able to insert expired events")
assertTrue(e is SQLiteConstraintException) } catch (e: Exception) {
assertTrue(e is SQLiteConstraintException)
}
} }
}
} }
@@ -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 com.vitorpamplona.quartz.utils.Log
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
@@ -82,6 +83,7 @@ class LargeDBTests {
} }
@Test @Test
@Ignore("Not testing")
fun insertDatabase() { fun insertDatabase() {
events.forEach { event -> events.forEach { event ->
try { try {
@@ -20,9 +20,7 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -30,140 +28,125 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase import junit.framework.TestCase
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Before
import org.junit.Test import org.junit.Test
class ReplaceableTest { class ReplaceableTest : BaseDBTest() {
private lateinit var db: EventStore
val signer = NostrSignerSync() val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testReplacing() { fun testReplacing() =
val time = TimeUtils.now() forEachDB { db ->
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) val time = TimeUtils.now()
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version1)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
@Test
fun testBlockingOldVersions() {
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))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1) db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) { db.assertQuery(version1, Filter(ids = listOf(version1.id)))
TestCase.assertTrue(e is SQLiteConstraintException) db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
} }
db.assertQuery(version3, Filter(ids = listOf(version3.id))) @Test
db.assertQuery(version3, addressableQuery) fun testBlockingOldVersions() =
db.assertQuery(null, Filter(ids = listOf(version2.id))) forEachDB { db ->
db.assertQuery(null, Filter(ids = listOf(version1.id))) 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))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test @Test
fun testTriggersIndexUsageKind0() { fun testTriggersIndexUsageKind0() =
val explainer = forEachDB { db ->
db.store.explainQuery( val sql =
""" """
SELECT * FROM event_headers SELECT * FROM event_headers
WHERE WHERE
event_headers.kind = 0 AND event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND event_headers.created_at < 1766686500
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); """.trimIndent()
""".trimIndent(),
)
assertEquals( val explainer = db.store.explainQuery(sql)
"""
SELECT * FROM event_headers assertEquals(
WHERE """
event_headers.kind = 0 AND SELECT * FROM event_headers
event_headers.pubkey = 'aa' AND WHERE
event_headers.created_at < 1766686500 AND event_headers.kind = 0 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); event_headers.pubkey = 'aa' AND
SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) event_headers.created_at < 1766686500
""".trimIndent(), SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
explainer, """.trimIndent(),
) explainer,
} )
}
@Test @Test
fun testTriggersIndexUsageKind3() { fun testTriggersIndexUsageKind3() =
val explainer = forEachDB { db ->
db.store.explainQuery( val sql =
""" """
SELECT * FROM event_headers SELECT * FROM event_headers
WHERE WHERE
event_headers.kind = 3 AND event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND event_headers.created_at < 1766686500
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); """.trimIndent()
""".trimIndent(),
)
assertEquals( val explainer = db.store.explainQuery(sql)
"""
SELECT * FROM event_headers assertEquals(
WHERE """
event_headers.kind = 3 AND SELECT * FROM event_headers
event_headers.pubkey = 'aa' AND WHERE
event_headers.created_at < 1766686500 AND event_headers.kind = 3 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); event_headers.pubkey = 'aa' AND
SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) event_headers.created_at < 1766686500
""".trimIndent(), SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
explainer, """.trimIndent(),
) explainer,
} )
}
} }
@@ -20,9 +20,7 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -30,107 +28,94 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test import org.junit.Test
class RightToVanishTest { class RightToVanishTest : BaseDBTest() {
private lateinit var db: EventStore
val signer = NostrSignerSync() val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null, relayUrl = "testUrl")
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testInsertDeleteEvent() { fun testInsertDeleteEvent() =
val time = TimeUtils.now() forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) val time = TimeUtils.now()
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
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))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1) db.insert(note1)
fail("Should not be able to insert a deleted event") db.insert(note2)
} catch (e: SQLiteConstraintException) { db.insert(note3)
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id))) db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
} val vanish = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
@Test @Test
fun testInsertDeleteGiftWrap() { fun testInsertDeleteGiftWrap() =
val time = TimeUtils.now() forEachDB { db ->
val time = TimeUtils.now()
val me = NostrSignerSync() val me = NostrSignerSync()
val myFriend = NostrSignerSync() val myFriend = NostrSignerSync()
val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey) val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
db.insert(wrap1)
db.insert(wrap2)
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))
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))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1) db.insert(wrap1)
fail("Should not be able to insert a deleted event") db.insert(wrap2)
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id))) db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
} 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("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
} }
@@ -20,20 +20,14 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite 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.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.After
import org.junit.Before
import org.junit.Test import org.junit.Test
class SearchTest { class SearchTest : BaseDBTest() {
private lateinit var db: SQLiteEventStore companion object {
companion object Companion {
val profile = val profile =
MetadataEvent( MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
@@ -74,35 +68,25 @@ class SearchTest {
) )
} }
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = SQLiteEventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test @Test
fun testTagWithSearch() { fun testTagWithSearch() =
db.insertEvent(comment) forEachDB { db ->
db.insertEvent(profile) db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(null, Filter(search = "testing1")) db.assertQuery(null, Filter(search = "testing1"))
db.assertQuery(comment, Filter(search = "testing")) db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.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(search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), 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(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
} }
} }
@@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
class DeletionRequestModule( class DeletionRequestModule(
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IModule { ) : 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 * Creates a trigger to reject events that have been
* deleted by ID or ATag including GiftWraps that * deleted by ID or ATag including GiftWraps that
* must be checked against the p-tag (pubkey_owner_hash) * must be checked against the p-tag (pubkey_owner_hash)
*/ */
override fun create(db: SQLiteDatabase) { override fun create(db: SQLiteDatabase) {
val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ")
db.execSQL( db.execSQL(
""" """
CREATE TRIGGER reject_deleted_events CREATE TRIGGER reject_deleted_events
BEFORE INSERT ON event_headers BEFORE INSERT ON event_headers
FOR EACH ROW FOR EACH ROW
BEGIN BEGIN
-- Check for ID-based deletion record
SELECT RAISE(ABORT, 'blocked: a deletion event exists') SELECT RAISE(ABORT, 'blocked: a deletion event exists')
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM event_tags $sql
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
); );
END; END;
""".trimIndent(), """.trimIndent(),
@@ -20,23 +20,16 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event 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.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.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.EventFactory
class EventIndexesModule( class EventIndexesModule(
val fts: FullTextSearchModule,
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IModule { ) : IModule {
override fun create(db: SQLiteDatabase) { override fun create(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
@@ -63,19 +56,53 @@ class EventIndexesModule(
CREATE TABLE event_tags ( CREATE TABLE event_tags (
event_header_row_id INTEGER NOT NULL, event_header_row_id INTEGER NOT NULL,
tag_hash 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 FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
) )
""".trimIndent(), """.trimIndent(),
) )
// queries by ID (load events)
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") 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 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 // Prevent updates to maintain immutability
db.execSQL( db.execSQL(
@@ -117,9 +144,9 @@ class EventIndexesModule(
val sqlInsertTags = val sqlInsertTags =
""" """
INSERT OR ROLLBACK INTO event_tags INSERT OR ROLLBACK INTO event_tags
(event_header_row_id, tag_hash) (event_header_row_id, tag_hash, created_at, kind, pubkey_hash)
VALUES VALUES
(?,?) (?,?,?,?,?)
""".trimIndent() """.trimIndent()
fun insert( fun insert(
@@ -169,7 +196,7 @@ class EventIndexesModule(
// rebalancing the tree every new insert // rebalancing the tree every new insert
val indexableTags = ArrayList<Long>() val indexableTags = ArrayList<Long>()
for (idx in event.tags.indices) { 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])) indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
} }
} }
@@ -177,387 +204,17 @@ class EventIndexesModule(
indexableTags.forEach { indexableTags.forEach {
stmtTags.bindLong(1, headerId) stmtTags.bindLong(1, headerId)
stmtTags.bindLong(2, it) stmtTags.bindLong(2, it)
stmtTags.bindLong(3, event.createdAt)
stmtTags.bindLong(4, kindLong)
stmtTags.bindLong(5, pubkeyHash)
stmtTags.executeInsert() stmtTags.executeInsert()
} }
return headerId 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) { override fun deleteAll(db: SQLiteDatabase) {
db.execSQL("DELETE FROM event_tags") db.execSQL("DELETE FROM event_tags")
db.execSQL("DELETE FROM event_headers") db.execSQL("DELETE FROM event_headers")
} }
data class RowIdSubQuery(
val sql: String,
val args: List<String>,
)
} }
@@ -29,9 +29,9 @@ class EventStore(
context: Context, context: Context,
dbName: String? = "events.db", dbName: String? = "events.db",
val relayUrl: String? = "wss://quartz.local", val relayUrl: String? = "wss://quartz.local",
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IEventStore { ) : IEventStore {
val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy) val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy)
override fun insert(event: Event) = store.insertEvent(event) 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 import com.vitorpamplona.quartz.nip01Core.core.Tag
interface IndexingStrategy { 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( fun shouldIndex(
kind: Int, kind: Int,
tag: Tag, tag: Tag,
@@ -32,7 +90,12 @@ interface IndexingStrategy {
/** /**
* By default, we index all tags that have a single letter name and some value * 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( override fun shouldIndex(
kind: Int, kind: Int,
tag: Tag, 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 WHERE
event_headers.kind = NEW.kind AND event_headers.kind = NEW.kind AND
event_headers.pubkey = NEW.pubkey AND event_headers.pubkey = NEW.pubkey AND
event_headers.created_at < NEW.created_at AND event_headers.created_at < NEW.created_at;
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
END; END;
""".trimIndent(), """.trimIndent(),
) )
@@ -27,10 +27,13 @@ import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction import androidx.core.database.sqlite.transaction
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -38,7 +41,7 @@ class SQLiteEventStore(
val context: Context, val context: Context,
val dbName: String? = "events.db", val dbName: String? = "events.db",
val relayUrl: String? = null, val relayUrl: String? = null,
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { ) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
companion object { companion object {
const val DATABASE_VERSION = 2 const val DATABASE_VERSION = 2
@@ -47,7 +50,7 @@ class SQLiteEventStore(
val seedModule = SeedModule() val seedModule = SeedModule()
val fullTextSearchModule = FullTextSearchModule() val fullTextSearchModule = FullTextSearchModule()
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy) val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy)
val replaceableModule = ReplaceableModule() val replaceableModule = ReplaceableModule()
val addressableModule = AddressableModule() val addressableModule = AddressableModule()
@@ -57,6 +60,8 @@ class SQLiteEventStore(
val expirationModule = ExpirationModule() val expirationModule = ExpirationModule()
val rightToVanishModule = RightToVanishModule(seedModule::hasher) val rightToVanishModule = RightToVanishModule(seedModule::hasher)
val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy)
val modules = val modules =
listOf( listOf(
seedModule, seedModule,
@@ -73,6 +78,9 @@ class SQLiteEventStore(
override fun onConfigure(db: SQLiteDatabase) { override fun onConfigure(db: SQLiteDatabase) {
super.onConfigure(db) super.onConfigure(db)
// 32MB memory cache
db.execSQL("PRAGMA cache_size=-32000;")
// makes sure the FKs are sane // makes sure the FKs are sane
db.setForeignKeyConstraintsEnabled(true) db.setForeignKeyConstraintsEnabled(true)
@@ -82,7 +90,14 @@ class SQLiteEventStore(
// The DB can be corrupted if the OS is shutdown before sync, which generally // The DB can be corrupted if the OS is shutdown before sync, which generally
// doesn't happen on Android // 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) { 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( fun <T : Event> query(
filter: Filter, filter: Filter,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) = eventIndexModule.query(filter, readableDatabase, onEach) ) = queryBuilder.query(filter, readableDatabase, onEach)
fun <T : Event> query( fun <T : Event> query(
filters: List<Filter>, filters: List<Filter>,
onEach: (T) -> Unit, 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) { fun delete(filter: Filter) {
eventIndexModule.delete(filter, writableDatabase) queryBuilder.delete(filter, writableDatabase)
} }
fun delete(filters: List<Filter>) { 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 delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) 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 package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
sealed class Condition { sealed class Condition {
data class Raw(
val condition: String,
) : Condition()
data class Equals( data class Equals(
val column: String, val column: String,
val value: Any?, val value: Any?,
@@ -81,4 +85,6 @@ sealed class Condition {
data class Or( data class Or(
val conditions: List<Condition>, val conditions: List<Condition>,
) : Condition() ) : Condition()
class Empty : Condition()
} }
@@ -38,13 +38,27 @@ class SqlSelectionBuilder(
*/ */
private fun buildCondition(cond: Condition): String = private fun buildCondition(cond: Condition): String =
when (cond) { when (cond) {
is Condition.Empty -> {
""
}
is Condition.Raw -> {
cond.condition
}
is Condition.Equals -> { is Condition.Equals -> {
selectionArgs.add(cond.value.toString()) if (cond.value == null) {
"${cond.column} = ?" "${cond.column} IS NULL"
} else {
selectionArgs.add(cond.value.toString())
"${cond.column} = ?"
}
} }
is Condition.NotEquals -> { is Condition.NotEquals -> {
selectionArgs.add(cond.value.toString()) if (cond.value == null) {
"${cond.column} != ?" "${cond.column} IS NULL"
} else {
selectionArgs.add(cond.value.toString())
"${cond.column} != ?"
}
} }
is Condition.GreaterThan -> { is Condition.GreaterThan -> {
selectionArgs.add(cond.value.toString()) selectionArgs.add(cond.value.toString())
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
class WhereClauseBuilder { class WhereClauseBuilder {
private val conditions = mutableListOf<Condition>() private val conditions = mutableListOf<Condition>()
fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) }
fun equals( fun equals(
column: String, column: String,
value: Any?, value: Any?,
@@ -86,7 +88,7 @@ class WhereClauseBuilder {
fun and(block: WhereClauseBuilder.() -> Unit) = fun and(block: WhereClauseBuilder.() -> Unit) =
apply { apply {
val builder = WhereClauseBuilder().apply(block) val builder = WhereClauseBuilder().apply(block)
val builtCondition = builder.build() val builtCondition = builder.buildAnd()
if (builtCondition != null) { if (builtCondition != null) {
conditions.add(builtCondition) conditions.add(builtCondition)
} }
@@ -95,22 +97,29 @@ class WhereClauseBuilder {
fun or(block: WhereClauseBuilder.() -> Unit) = fun or(block: WhereClauseBuilder.() -> Unit) =
apply { apply {
val builder = WhereClauseBuilder().apply(block) val builder = WhereClauseBuilder().apply(block)
val builtCondition = builder.build() val builtCondition = builder.buildOr()
if (builtCondition != null) { if (builtCondition != null) {
conditions.add(builtCondition) conditions.add(builtCondition)
} }
} }
fun build(): Condition? = fun buildAnd(): Condition? =
when (conditions.size) { when (conditions.size) {
0 -> null 0 -> null
1 -> conditions.first() 1 -> conditions.first()
else -> Condition.And(conditions.toList()) 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 { 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() return SqlSelectionBuilder(condition).build()
} }
@@ -44,7 +44,13 @@ class AddressSerializer {
return try { return try {
val parts = addressId.split(":", limit = 3) val parts = addressId.split(":", limit = 3)
if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) { if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) {
Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") 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 { } else {
if (addressId.startsWith("naddr1")) { if (addressId.startsWith("naddr1")) {
val addr = Nip19Parser.uriToRoute(addressId)?.entity val addr = Nip19Parser.uriToRoute(addressId)?.entity
@@ -60,7 +66,7 @@ class AddressSerializer {
} }
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) Log.w("AddressableId", "Error parsing: $addressId: ${t.message}", t)
null null
} }
} }
@@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
typealias Kind = Int 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.isEphemeral() = this >= 20_000 && this < 30_000
fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000) fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000)
@@ -54,6 +54,7 @@ object JsonMapper {
val jsonInstance = val jsonInstance =
Json { Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true
} }
inline fun <reified T> fromJson(json: String): T = jsonInstance.decodeFromString<T>(json) 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>> { fun List<RelayBasedFilter>.groupByRelay(): Map<NormalizedRelayUrl, List<Filter>> {
val result = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>() val result = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
for (relayBasedFilter in this) { for (relayBasedFilter in this) {
if (relayBasedFilter.filter.isFilledFilter()) { if (relayBasedFilter.filter.isEmpty()) {
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
} else {
Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}") Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}")
} else {
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
} }
} }
return result return result
@@ -128,7 +128,7 @@ open class BasicRelayClient(
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
// doesn't expose parsing errors to lib users as errors // 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 package com.vitorpamplona.quartz.nip01Core.relay.client.stats
import androidx.collection.LruCache import androidx.collection.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class RelayStat( class RelayStat(
var receivedBytes: Int = 0, var receivedBytes: Int = 0,
var sentBytes: Int = 0, var sentBytes: Int = 0,
@@ -31,12 +33,11 @@ class RelayStat(
var pingInMs: Int = 0, var pingInMs: Int = 0,
var compression: Boolean = false, var compression: Boolean = false,
) { ) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100) val messages = LruCache<IRelayDebugMessage, IRelayDebugMessage>(100)
fun newNotice(notice: String?) { fun newNotice(notice: String?) {
val debugMessage = val debugMessage =
RelayDebugMessage( NoticeDebugMessage(
type = RelayDebugMessageType.NOTICE,
message = notice ?: "No error message provided", message = notice ?: "No error message provided",
) )
@@ -47,8 +48,7 @@ class RelayStat(
errorCounter++ errorCounter++
val debugMessage = val debugMessage =
RelayDebugMessage( ErrorDebugMessage(
type = RelayDebugMessageType.ERROR,
message = error ?: "No error message provided", message = error ?: "No error message provided",
) )
@@ -63,27 +63,35 @@ class RelayStat(
sentBytes += bytesUsedInMemory sentBytes += bytesUsedInMemory
} }
fun newSpam(spamDescriptor: String) { fun newSpam(
link1: String,
link2: String,
) {
spamCounter++ spamCounter++
val debugMessage = val debugMessage = SpamDebugMessage(link1, link2)
RelayDebugMessage(
type = RelayDebugMessageType.SPAM,
message = spamDescriptor,
)
messages.put(debugMessage, debugMessage) messages.put(debugMessage, debugMessage)
} }
} }
class RelayDebugMessage( @Stable
val type: RelayDebugMessageType, sealed interface IRelayDebugMessage {
val message: String, val time: Long
val time: Long = TimeUtils.now(),
)
enum class RelayDebugMessageType {
SPAM,
NOTICE,
ERROR,
} }
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.Address
import com.vitorpamplona.quartz.nip01Core.core.Event 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.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.utils.Log 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). * - authors: Optional list of author public keys (must be 64 characters).
* - kinds: Optional list of event kinds to include. * - 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). * - 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. * - since: Optional timestamp for filtering events with publication time this value.
* - until: 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. * - 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. * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs.
*/ */
class Filter( class Filter(
val ids: List<String>? = null, val ids: List<HexKey>? = null,
val authors: List<String>? = null, val authors: List<HexKey>? = null,
val kinds: List<Int>? = null, val kinds: List<Kind>? = null,
val tags: Map<String, List<String>>? = null, val tags: Map<String, List<String>>? = null,
val tagsAll: Map<String, List<String>>? = null,
val since: Long? = null, val since: Long? = null,
val until: Long? = null, val until: Long? = null,
val limit: Int? = null, val limit: Int? = null,
@@ -55,31 +59,33 @@ class Filter(
) : OptimizedSerializable { ) : OptimizedSerializable {
fun toJson() = OptimizedJsonMapper.toJson(this) 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( fun copy(
ids: List<String>? = this.ids, ids: List<String>? = this.ids,
authors: List<String>? = this.authors, authors: List<String>? = this.authors,
kinds: List<Int>? = this.kinds, kinds: List<Int>? = this.kinds,
tags: Map<String, List<String>>? = this.tags, tags: Map<String, List<String>>? = this.tags,
tagsAll: Map<String, List<String>>? = this.tagsAll,
since: Long? = this.since, since: Long? = this.since,
until: Long? = this.until, until: Long? = this.until,
limit: Int? = this.limit, limit: Int? = this.limit,
search: String? = this.search, 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() = fun isEmpty() =
(ids != null && ids.isNotEmpty()) || (ids == null || ids.isEmpty()) &&
(authors != null && authors.isNotEmpty()) || (authors == null || authors.isEmpty()) &&
(kinds != null && kinds.isNotEmpty()) || (kinds == null || kinds.isEmpty()) &&
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) || (tags == null || tags.isEmpty() && tags.values.all { it.isNotEmpty() }) &&
(since != null) || (tagsAll == null || tagsAll.isEmpty() && tagsAll.values.all { it.isNotEmpty() }) &&
(until != null) || (since == null) &&
(limit != null) || (until == null) &&
(search != null && search.isNotEmpty()) (limit == null) &&
(search == null || search.isEmpty())
init { init {
ids?.forEach { ids?.forEach {
@@ -89,14 +95,27 @@ class Filter(
if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}") if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}")
} }
// tests common tags. // tests common tags.
tags?.get("p")?.forEach { if (tags != null) {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") tags["p"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
}
tags["e"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tags["a"]?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
} }
tags?.get("e")?.forEach { if (tagsAll != null) {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") tagsAll["p"]?.forEach {
} if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
tags?.get("a")?.forEach { }
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $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, authors: List<String>? = null,
kinds: List<Int>? = null, kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null, tags: Map<String, List<String>>? = null,
tagsAll: Map<String, List<String>>? = null,
since: Long? = null, since: Long? = null,
until: Long? = null, until: Long? = null,
): Boolean { ): Boolean {
@@ -36,7 +37,23 @@ object FilterMatcher {
if (kinds?.contains(event.kind) == false) return false if (kinds?.contains(event.kind) == false) return false
if (authors?.contains(event.pubKey) == false) return false if (authors?.contains(event.pubKey) == false) return false
tags?.forEach { tag -> 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)) { if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false return false
@@ -20,6 +20,9 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.relay.normalizer package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import androidx.compose.runtime.Stable
@Stable
data class NormalizedRelayUrl( data class NormalizedRelayUrl(
val url: String, val url: String,
) : Comparable<NormalizedRelayUrl> { ) : Comparable<NormalizedRelayUrl> {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip11RelayInfo package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.text
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
@@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
object FlexibleIntListSerializer : KSerializer<List<Int>?> { object FlexibleIntListSerializer : KSerializer<List<String>?> {
private val listSerializer = ListSerializer(Int.serializer()) private val listSerializer = ListSerializer(String.serializer())
override val descriptor: SerialDescriptor = listSerializer.descriptor 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" } require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
return when (val element = decoder.decodeJsonElement()) { return when (val element = decoder.decodeJsonElement()) {
@@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
is JsonArray -> { is JsonArray -> {
element.mapNotNull { arrayElement -> element.mapNotNull { arrayElement ->
try { try {
arrayElement.jsonPrimitive.int arrayElement.jsonPrimitive.text
} catch (e: Exception) { } catch (e: Exception) {
// Skip elements that aren't valid integers (strings, booleans, floats, etc.) // Skip elements that aren't valid integers (strings, booleans, floats, etc.)
Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e) 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 // Handle single integer format (malformed but found in the wild): 1
is JsonPrimitive if !element.isString -> { is JsonPrimitive if !element.isString -> {
try { try {
listOf(element.int) listOf(element.text)
} catch (e: Exception) { } catch (e: Exception) {
// Can't parse as integer (e.g., float, boolean), treat as missing data // Can't parse as integer (e.g., float, boolean), treat as missing data
Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e) Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e)
@@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
override fun serialize( override fun serialize(
encoder: Encoder, encoder: Encoder,
value: List<Int>?, value: List<String>?,
) { ) {
if (value == null) { if (value == null) {
encoder.encodeNull() encoder.encodeNull()
@@ -21,19 +21,22 @@
package com.vitorpamplona.quartz.nip11RelayInfo package com.vitorpamplona.quartz.nip11RelayInfo
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Stable
@Serializable @Serializable
class Nip11RelayInformation( class Nip11RelayInformation(
val id: String? = null, val id: String? = null,
val name: String? = null, val name: String? = null,
val description: String? = null, val description: String? = null,
val icon: String? = null, val icon: String? = null,
val pubkey: String? = null, val pubkey: HexKey? = null,
val self: HexKey? = null,
val contact: String? = null, val contact: String? = null,
@Serializable(with = FlexibleIntListSerializer::class) @Serializable(with = FlexibleIntListSerializer::class)
val supported_nips: List<Int>? = null, val supported_nips: List<String>? = null,
val supported_nip_extensions: List<String>? = null, val supported_nip_extensions: List<String>? = null,
val software: String? = null, val software: String? = null,
val version: String? = null, val version: String? = null,
@@ -42,53 +45,60 @@ class Nip11RelayInformation(
val language_tags: List<String>? = null, val language_tags: List<String>? = null,
val tags: List<String>? = null, val tags: List<String>? = null,
val posting_policy: String? = null, val posting_policy: String? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val payments_url: String? = null, val payments_url: String? = null,
val retention: List<RelayInformationRetentionData>? = null, val retention: List<RelayInformationRetentionData>? = null,
val fees: RelayInformationFees? = null, val fees: RelayInformationFees? = null,
val nip50: List<String>? = null, val nip50: List<String>? = null,
val supported_grasps: List<String>? = null,
) { ) {
companion object { companion object {
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json) fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
} }
@Stable
@Serializable
class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
val kinds: List<Int>? = null,
)
@Stable
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
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,
val max_content_length: Int? = null,
val min_pow_difficulty: Int? = null,
val auth_required: Boolean? = null,
val payment_required: Boolean? = null,
val restricted_writes: Boolean? = null,
val created_at_lower_limit: Int? = null,
val created_at_upper_limit: Int? = null,
)
@Stable
@Serializable
class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
)
} }
@Stable
@Serializable
class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
val kinds: List<Int>? = null,
)
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null,
)
@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 max_subid_length: Int? = null,
val min_prefix: Int? = null,
val max_event_tags: Int? = null,
val max_content_length: Int? = null,
val min_pow_difficulty: Int? = null,
val auth_required: Boolean? = null,
val payment_required: Boolean? = null,
val restricted_writes: Boolean? = null,
val created_at_lower_limit: Int? = null,
val created_at_upper_limit: Int? = null,
)
@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( suspend fun resign(
tags: TagArray, tags: TagArray,
privateTags: TagArray, privateTags: TagArray,
@@ -20,11 +20,13 @@
*/ */
package com.vitorpamplona.quartz.nip94FileMetadata.tags package com.vitorpamplona.quartz.nip94FileMetadata.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.ensure
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@Stable
class DimensionTag( class DimensionTag(
val width: Int, val width: Int,
val height: 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("purplepag.es", info.name)
assertEquals("Nostr's Purple Pages", info.description) assertEquals("Nostr's Purple Pages", info.description)
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey) 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("1.0.4", info.version)
assertEquals("git+https://github.com/hoytech/strfry.git", info.software) assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
} }
@@ -88,7 +88,7 @@ class Nip11RelayInformationTest {
assertEquals("Nostr's Purple Pages", info.description) assertEquals("Nostr's Purple Pages", info.description)
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey) assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
// Single integer should be converted to a list with one element // 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("1.0.4", info.version)
assertEquals("git+https://github.com/hoytech/strfry.git", info.software) assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
} }
@@ -132,7 +132,7 @@ class Nip11RelayInformationTest {
assertNotNull(info) assertNotNull(info)
assertEquals("test relay", info.name) assertEquals("test relay", info.name)
// Should skip invalid elements and keep only valid integers // 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 @Test
@@ -144,7 +144,7 @@ class Nip11RelayInformationTest {
assertNotNull(info) assertNotNull(info)
assertEquals("test relay", info.name) assertEquals("test relay", info.name)
// All elements invalid, should result in empty list // All elements invalid, should result in empty list
assertEquals(emptyList(), info.supported_nips) assertEquals(listOf("one", "two", "three"), info.supported_nips)
} }
@Test @Test
@@ -156,7 +156,7 @@ class Nip11RelayInformationTest {
assertNotNull(info) assertNotNull(info)
assertEquals("test relay", info.name) assertEquals("test relay", info.name)
// Cannot parse float as integer, should be null // Cannot parse float as integer, should be null
assertNull(info.supported_nips) assertEquals(listOf("1.5"), info.supported_nips)
} }
@Test @Test
@@ -168,7 +168,7 @@ class Nip11RelayInformationTest {
assertNotNull(info) assertNotNull(info)
assertEquals("test relay", info.name) assertEquals("test relay", info.name)
// Cannot parse boolean as integer, should be null // Cannot parse boolean as integer, should be null
assertNull(info.supported_nips) assertEquals(listOf("true"), info.supported_nips)
} }
@Test @Test
@@ -204,6 +204,6 @@ class Nip11RelayInformationTest {
assertNotNull(info) assertNotNull(info)
assertEquals("test relay", info.name) 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") val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug")
assertNotNull(actual) assertNotNull(actual)
assertTrue(actual?.entity is NProfile) assertTrue(actual.entity is NProfile)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex) 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() @Test()
@@ -267,7 +267,7 @@ class NIP19ParserTest {
"31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far",
result.aTag(), result.aTag(),
) )
assertEquals(true, result.relay?.isEmpty()) assertEquals(true, result.relay.isEmpty())
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author)
assertEquals(31337, result.kind) assertEquals(31337, result.kind)
} }
@@ -285,7 +285,7 @@ class NIP19ParserTest {
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509",
result.aTag(), result.aTag(),
) )
assertEquals(true, result.relay?.isEmpty()) assertEquals(true, result.relay.isEmpty())
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
assertEquals(30023, result.kind) assertEquals(30023, result.kind)
} }
@@ -303,7 +303,7 @@ class NIP19ParserTest {
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418",
result.aTag(), result.aTag(),
) )
assertEquals(true, result.relay?.isEmpty()) assertEquals(true, result.relay.isEmpty())
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
assertEquals(30023, result.kind) assertEquals(30023, result.kind)
} }
@@ -356,7 +356,7 @@ class NIP19ParserTest {
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex) assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex)
assertEquals( assertEquals(
NormalizedRelayUrl("wss://nostr.mom/"), NormalizedRelayUrl("wss://nostr.mom/"),
result.relay?.get(0), result.relay[0],
) )
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author)
assertEquals(1, result.kind) assertEquals(1, result.kind)
@@ -403,7 +403,7 @@ class NIP19ParserTest {
assertNotNull(result) assertNotNull(result)
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex) 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("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author)
assertEquals(1, result.kind) assertEquals(1, result.kind)
} }
@@ -24,7 +24,6 @@ import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo import kotlinx.cinterop.refTo
import platform.Security.SecRandomCopyBytes import platform.Security.SecRandomCopyBytes
import platform.Security.kSecRandomDefault import platform.Security.kSecRandomDefault
import kotlin.random.Random.Default.nextBytes
actual class SecureRandom { actual class SecureRandom {
actual fun nextInt(): Int { 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) 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.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
import kotlinx.serialization.json.JsonNull.content
import java.io.InputStream import java.io.InputStream
class JacksonMapper { class JacksonMapper {
@@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
class ManualFilterDeserializer { class ManualFilterDeserializer {
companion object { companion object {
fun fromJson(jsonObject: ObjectNode): Filter { fun fromJson(jsonObject: ObjectNode): Filter {
val tags = mutableListOf<String>() val tagsIn = mutableListOf<String>()
jsonObject.fieldNames().forEach { jsonObject.fieldNames().forEach {
if (it.startsWith("#")) { 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() }, ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() }, authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() }, 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(), since = jsonObject.get("since").asLongOrNull(),
until = jsonObject.get("until").asLongOrNull(), until = jsonObject.get("until").asLongOrNull(),
limit = jsonObject.get("limit").asIntOrNull(), 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.since?.run { gen.writeNumberField("since", this) }
filter.until?.run { gen.writeNumberField("until", this) } filter.until?.run { gen.writeNumberField("until", this) }
filter.limit?.run { gen.writeNumberField("limit", this) } filter.limit?.run { gen.writeNumberField("limit", this) }