Merge branch 'main' into claude/implement-nip-85-hBlbp

This commit is contained in:
Vitor Pamplona
2026-03-28 11:35:19 -04:00
committed by GitHub
66 changed files with 2503 additions and 20 deletions
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
@@ -62,6 +63,7 @@ class RelaySubscriptionsCoordinator(
val chatroomList = ChatroomListFilterAssembler(client) val chatroomList = ChatroomListFilterAssembler(client)
val video = VideoFilterAssembler(client) val video = VideoFilterAssembler(client)
val discovery = DiscoveryFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client)
val polls = PollsFilterAssembler(client)
// loaders of content that is not yet in the device. // loaders of content that is not yet in the device.
// they are active when looking at events, users, channels. // they are active when looking at events, users, channels.
@@ -95,6 +97,7 @@ class RelaySubscriptionsCoordinator(
chatroomList, chatroomList,
video, video,
discovery, discovery,
polls,
channelFinder, channelFinder,
eventFinder, eventFinder,
userFinder, userFinder,
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.components
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -301,6 +303,15 @@ private fun DialogContent(
} }
} }
private fun showToastOnMain(
context: Context,
resId: Int,
) {
Handler(Looper.getMainLooper()).post {
Toast.makeText(context.applicationContext, resId, Toast.LENGTH_SHORT).show()
}
}
private suspend fun saveMediaToGallery( private suspend fun saveMediaToGallery(
content: BaseMediaContent, content: BaseMediaContent,
localContext: Context, localContext: Context,
@@ -324,7 +335,7 @@ private suspend fun saveMediaToGallery(
}, },
localContext, localContext,
onSuccess = { onSuccess = {
accountViewModel.toastManager.toast(success, success) showToastOnMain(localContext, success)
}, },
onError = { onError = {
accountViewModel.toastManager.toast(failure, null, it) accountViewModel.toastManager.toast(failure, null, it)
@@ -337,7 +348,7 @@ private suspend fun saveMediaToGallery(
content.mimeType, content.mimeType,
localContext, localContext,
onSuccess = { onSuccess = {
accountViewModel.toastManager.toast(success, success) showToastOnMain(localContext, success)
}, },
onError = { innerIt -> onError = { innerIt ->
accountViewModel.toastManager.toast(failure, null, innerIt) accountViewModel.toastManager.toast(failure, null, innerIt)
@@ -55,6 +55,8 @@ object ScrollStateKeys {
const val DISCOVER_COMMUNITY = "DiscoverCommunitiesFeed" const val DISCOVER_COMMUNITY = "DiscoverCommunitiesFeed"
const val DISCOVER_CHATS = "DiscoverChatsFeed" const val DISCOVER_CHATS = "DiscoverChatsFeed"
const val POLLS_SCREEN = "PollsFeed"
const val SEARCH_SCREEN = "SearchFeed" const val SEARCH_SCREEN = "SearchFeed"
const val WEB_BOOKMARKS = "WebBookmarksFeed" const val WEB_BOOKMARKS = "WebBookmarksFeed"
@@ -101,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPic
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
@@ -172,6 +173,7 @@ fun BuildNavigation(
composable<Route.Video> { VideoScreen(accountViewModel, nav) } composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) } composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) } composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) } composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) } composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
@@ -574,6 +574,15 @@ fun ListContent(
route = Route.Drafts, route = Route.Drafts,
) )
NavigationRow(
title = R.string.polls,
icon = R.drawable.ic_poll,
iconReference = 1,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Polls,
)
NavigationRow( NavigationRow(
title = R.string.wallet, title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet, icon = Icons.Outlined.AccountBalanceWallet,
@@ -41,6 +41,8 @@ sealed class Route {
@Serializable object Notification : Route() @Serializable object Notification : Route()
@Serializable object Polls : Route()
@Serializable object Chess : Route() @Serializable object Chess : Route()
@Serializable object Wallet : Route() @Serializable object Wallet : Route()
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedConte
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -69,6 +70,8 @@ class AccountFeedContentStates(
val discoverCommunities = FeedContentState(DiscoverCommunityFeedFilter(account), scope, LocalCache) val discoverCommunities = FeedContentState(DiscoverCommunityFeedFilter(account), scope, LocalCache)
val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache) val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache)
val pollsFeed = FeedContentState(PollsFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationsOpenPolls = OpenPollsState(account, scope) val notificationsOpenPolls = OpenPollsState(account, scope)
val notificationSummary = NotificationSummaryState(account) val notificationSummary = NotificationSummaryState(account)
@@ -103,6 +106,8 @@ class AccountFeedContentStates(
discoverCommunities.updateFeedWith(newNotes) discoverCommunities.updateFeedWith(newNotes)
discoverPublicChats.updateFeedWith(newNotes) discoverPublicChats.updateFeedWith(newNotes)
pollsFeed.updateFeedWith(newNotes)
notifications.updateFeedWith(newNotes) notifications.updateFeedWith(newNotes)
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
@@ -131,6 +136,8 @@ class AccountFeedContentStates(
discoverCommunities.deleteFromFeed(newNotes) discoverCommunities.deleteFromFeed(newNotes)
discoverPublicChats.deleteFromFeed(newNotes) discoverPublicChats.deleteFromFeed(newNotes)
pollsFeed.deleteFromFeed(newNotes)
notifications.deleteFromFeed(newNotes) notifications.deleteFromFeed(newNotes)
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
@@ -23,7 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import android.util.LruCache import android.util.LruCache
import android.widget.Toast
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@@ -1709,7 +1712,11 @@ class AccountViewModel(
mimeType = mimeType, mimeType = mimeType,
localContext = localContext, localContext = localContext,
onSuccess = { onSuccess = {
toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery) Handler(Looper.getMainLooper()).post {
Toast
.makeText(localContext.applicationContext, R.string.video_saved_to_the_gallery, Toast.LENGTH_SHORT)
.show()
}
}, },
onError = { onError = {
toastManager.toast(R.string.failed_to_save_the_video, null, it) toastManager.toast(R.string.failed_to_save_the_video, null, it)
@@ -0,0 +1,105 @@
/*
* 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.polls
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription
@Composable
fun PollsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
PollsScreen(
pollsFeedContentState = accountViewModel.feedStates.pollsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun PollsScreen(
pollsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(pollsFeedContentState)
WatchAccountForPollsScreen(pollsFeedContentState = pollsFeedContentState, accountViewModel = accountViewModel)
PollsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
PollsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Polls, accountViewModel) { route ->
if (route == Route.Polls) {
pollsFeedContentState.sendToTop()
} else {
nav.newStack(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(pollsFeedContentState, true) {
SaveableFeedContentState(pollsFeedContentState, scrollStateKey = ScrollStateKeys.POLLS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = pollsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "PollsFeed",
)
}
}
}
}
@Composable
fun WatchAccountForPollsScreen(
pollsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveDiscoveryFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
pollsFeedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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.polls
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun PollsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultDiscoveryFollowList
.collectAsStateWithLifecycle()
PollsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultDiscoveryFollowList,
)
}
}
@Composable
private fun PollsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,78 @@
/*
* 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.polls.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
class PollsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is PollEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveDiscoveryFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is PollEvent && params.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,48 @@
/*
* 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.polls.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PollsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
class PollsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<PollsQueryState>() {
val group =
listOf(
PollsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* 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.polls.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun PollsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
PollsFilterAssemblerSubscription(
accountViewModel.dataSources().polls,
accountViewModel,
)
}
@Composable
fun PollsFilterAssemblerSubscription(
dataSource: PollsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
PollsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,97 @@
/*
* 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.polls.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class PollsSubAssembler(
client: INostrClient,
allKeys: () -> Set<PollsQueryState>,
) : PerUserAndFollowListEoseManager<PollsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: PollsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makePollsFilter(feedSettings, since, key.feedStates.pollsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: PollsQueryState) = key.account.userProfile()
override fun list(key: PollsQueryState) = key.listName()
fun PollsQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList
fun PollsQueryState.listName() = listNameFlow().value
fun PollsQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay
fun PollsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: PollsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.pollsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,49 @@
/*
* 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.polls.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makePollsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet -> filterPollsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterPollsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPollsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterPollsByHashtag(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPollsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,92 @@
/*
* 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.polls.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
fun filterPollsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(PollEvent.KIND),
limit = 200,
since = since,
),
),
)
}
fun filterPollsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterPollsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterPollsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterPollsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,44 @@
/*
* 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.polls.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterPollsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val since = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let {
filterPollsByAuthors(relay, it, since)
},
).flatten()
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
fun filterPollsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(PollEvent.KIND),
tags = mapOf("t" to hashtags.toList()),
limit = 200,
since = since,
),
),
)
fun filterPollsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterPollsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,49 @@
/*
* 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.polls.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterPollsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(PollEvent.KIND),
limit = 200,
since = since,
),
)
}
}
+1
View File
@@ -411,6 +411,7 @@
<string name="bookmarks_title">Default Bookmarks</string> <string name="bookmarks_title">Default Bookmarks</string>
<string name="bookmarks_explainer">Your default Bookmarks that many clients support</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="polls">Polls</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>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string> <string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
+50 -4
View File
@@ -75,6 +75,8 @@ kotlin {
} }
} }
linuxX64()
// This makes sure that the resource file directory is visible for iOS tests. // This makes sure that the resource file directory is visible for iOS tests.
val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources" val rootDir = "${rootProject.rootDir.path}/quartz/src/commonTest/resources"
@@ -222,8 +224,21 @@ kotlin {
} }
} }
iosMain { // Must be defined before appleMain, linuxMain, etc.
val nativeMain =
create("nativeMain") {
dependsOn(commonMain.get()) dependsOn(commonMain.get())
}
val nativeTest =
create("nativeTest") {
dependsOn(commonTest.get())
}
// Must be defined before iosMain and macosMain
val appleMain =
create("appleMain") {
dependsOn(nativeMain)
dependencies { dependencies {
implementation(libs.charlietap.cachemap) implementation(libs.charlietap.cachemap)
implementation(libs.net.thauvin.erik.urlencoder.lib) implementation(libs.net.thauvin.erik.urlencoder.lib)
@@ -233,6 +248,15 @@ kotlin {
} }
} }
val appleTest =
create("appleTest") {
dependsOn(nativeTest)
}
iosMain {
dependsOn(appleMain)
}
val iosArm64Main by getting { val iosArm64Main by getting {
dependsOn(iosMain.get()) dependsOn(iosMain.get())
} }
@@ -242,9 +266,7 @@ kotlin {
} }
iosTest { iosTest {
dependsOn(commonTest.get()) dependsOn(appleTest)
dependencies {
}
} }
val iosArm64Test by getting { val iosArm64Test by getting {
@@ -254,6 +276,30 @@ kotlin {
val iosSimulatorArm64Test by getting { val iosSimulatorArm64Test by getting {
dependsOn(iosTest.get()) dependsOn(iosTest.get())
} }
val linuxMain =
create("linuxMain") {
dependsOn(nativeMain)
dependencies {
implementation(libs.net.thauvin.erik.urlencoder.lib)
implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal)
implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4")
implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4")
}
}
val linuxTest =
create("linuxTest") {
dependsOn(nativeTest)
}
val linuxX64Main by getting {
dependsOn(linuxMain)
}
val linuxX64Test by getting {
dependsOn(linuxTest)
}
} }
} }
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -101,6 +102,7 @@ class LnZapRequestEvent(
arrayOf("e", zappedEvent.id), arrayOf("e", zappedEvent.id),
arrayOf("p", toUserPubHex ?: zappedEvent.pubKey), arrayOf("p", toUserPubHex ?: zappedEvent.pubKey),
arrayOf("relays") + relays.map { it.url }, arrayOf("relays") + relays.map { it.url },
KindTag.assemble(zappedEvent.kind),
AltTag.assemble(ALT), AltTag.assemble(ALT),
) )
if (zappedEvent is AddressableEvent) { if (zappedEvent is AddressableEvent) {
@@ -83,6 +83,7 @@ class PublicMessageEvent(
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
initializer() initializer()
remove("e") // NIP-A4: e tags must not be used
} }
fun build( fun build(
@@ -94,6 +95,7 @@ class PublicMessageEvent(
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
toUser(to) toUser(to)
initializer() initializer()
remove("e") // NIP-A4: e tags must not be used
} }
fun build( fun build(
@@ -105,6 +107,7 @@ class PublicMessageEvent(
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
toGroup(to) toGroup(to)
initializer() initializer()
remove("e") // NIP-A4: e tags must not be used
} }
} }
} }
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip57Zaps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LnZapRequestKindTagTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val nostrSigner = NostrSignerInternal(signer.key)
private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
@Test
fun `zap request for kind 24 event includes k tag`() =
runTest {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test message")
val publicMsg = signer.sign<PublicMessageEvent>(template)
val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
val zapRequest =
LnZapRequestEvent.create(
zappedEvent = publicMsg,
relays = relays,
signer = nostrSigner,
pollOption = null,
message = "",
zapType = LnZapEvent.ZapType.PUBLIC,
toUserPubHex = null,
)
val kTag = zapRequest.tags.firstOrNull { it[0] == "k" }
assertTrue(kTag != null, "Zap request must include k tag per NIP-A4")
assertEquals("24", kTag[1], "k tag must contain the zapped event's kind")
}
@Test
fun `zap request for kind 1 event includes k tag`() =
runTest {
val kind1Event =
Event(
id = "a".repeat(64),
pubKey = receiverPubKey,
createdAt = 1000L,
kind = 1,
tags = emptyArray(),
content = "Hello world",
sig = "b".repeat(128),
)
val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
val zapRequest =
LnZapRequestEvent.create(
zappedEvent = kind1Event,
relays = relays,
signer = nostrSigner,
pollOption = null,
message = "",
zapType = LnZapEvent.ZapType.PUBLIC,
toUserPubHex = null,
)
val kTag = zapRequest.tags.firstOrNull { it[0] == "k" }
assertTrue(kTag != null, "Zap request should include k tag")
assertEquals("1", kTag[1], "k tag should contain the zapped event's kind")
}
@Test
fun `zap request for user only has no k tag`() =
runTest {
val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
val zapRequest =
LnZapRequestEvent.create(
userHex = receiverPubKey,
relays = relays,
signer = nostrSigner,
message = "",
zapType = LnZapEvent.ZapType.PUBLIC,
)
val kTag = zapRequest.tags.firstOrNull { it[0] == "k" }
assertEquals(null, kTag, "User-only zap should not have k tag")
}
}
@@ -0,0 +1,184 @@
/*
* 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.nipA4PublicMessages
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PublicMessageEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val receiverRelay = "wss://relay.example.com/"
@Test
fun `verify event kind is 24`() {
assertEquals(24, PublicMessageEvent.KIND)
}
@Test
fun `verify alt description`() {
assertEquals("Public Message", PublicMessageEvent.ALT_DESCRIPTION)
}
@Test
fun `build with single receiver`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Hello!")
val event = signer.sign<PublicMessageEvent>(template)
assertEquals(24, event.kind)
assertEquals("Hello!", event.content)
assertTrue(event.tags.any { it[0] == "p" && it[1] == receiverPubKey })
assertTrue(event.tags.any { it[0] == "alt" && it[1] == "Public Message" })
}
@Test
fun `build with multiple receivers`() {
val receiver1 = ReceiverTag(receiverPubKey, null)
val receiver2PubKey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
val receiver2 = ReceiverTag(receiver2PubKey, null)
val template = PublicMessageEvent.build(listOf(receiver1, receiver2), "Group message")
val event = signer.sign<PublicMessageEvent>(template)
assertEquals("Group message", event.content)
val pTags = event.tags.filter { it[0] == "p" }
assertEquals(2, pTags.size)
assertTrue(pTags.any { it[1] == receiverPubKey })
assertTrue(pTags.any { it[1] == receiver2PubKey })
}
@Test
fun `build with relay hint`() {
val receiver = ReceiverTag.parse(arrayOf("p", receiverPubKey, "wss://relay.example.com/"))!!
val template = PublicMessageEvent.build(receiver, "With relay hint")
val event = signer.sign<PublicMessageEvent>(template)
val pTag = event.tags.first { it[0] == "p" && it[1] == receiverPubKey }
assertEquals("wss://relay.example.com/", pTag[2])
}
@Test
fun `e tags are stripped from build output`() {
val receiver = ReceiverTag(receiverPubKey, null)
val fakeEventId = "a".repeat(64)
val template =
PublicMessageEvent.build(receiver, "Test message") {
eTag(ETag(fakeEventId, null))
}
val event = signer.sign<PublicMessageEvent>(template)
assertFalse(
event.tags.any { it[0] == "e" },
"NIP-A4 prohibits e tags in kind 24 events",
)
}
@Test
fun `e tags are stripped from empty build`() {
val fakeEventId = "b".repeat(64)
val template =
PublicMessageEvent.build {
eTag(ETag(fakeEventId, null))
}
val event = signer.sign<PublicMessageEvent>(template)
assertFalse(
event.tags.any { it[0] == "e" },
"NIP-A4 prohibits e tags in kind 24 events",
)
}
@Test
fun `e tags are stripped from group build`() {
val receiver = ReceiverTag(receiverPubKey, null)
val fakeEventId = "c".repeat(64)
val template =
PublicMessageEvent.build(listOf(receiver), "Group test") {
eTag(ETag(fakeEventId, null))
}
val event = signer.sign<PublicMessageEvent>(template)
assertFalse(
event.tags.any { it[0] == "e" },
"NIP-A4 prohibits e tags in kind 24 events",
)
}
@Test
fun `isIncluded checks receivers and author`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
assertTrue(event.isIncluded(receiverPubKey))
assertTrue(event.isIncluded(event.pubKey))
assertFalse(event.isIncluded("0".repeat(64)))
}
@Test
fun `groupKeys includes author and receivers`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
val keys = event.groupKeys()
assertTrue(keys.contains(event.pubKey))
assertTrue(keys.contains(receiverPubKey))
}
@Test
fun `groupKeySet returns unique keys`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
val keySet = event.groupKeySet()
assertEquals(2, keySet.size)
assertTrue(keySet.contains(event.pubKey))
assertTrue(keySet.contains(receiverPubKey))
}
@Test
fun `chatroomKey excludes given user`() {
val receiver = ReceiverTag(receiverPubKey, null)
val template = PublicMessageEvent.build(receiver, "Test")
val event = signer.sign<PublicMessageEvent>(template)
val chatroomKey = event.chatroomKey(event.pubKey)
assertFalse(chatroomKey.contains(event.pubKey))
assertTrue(chatroomKey.contains(receiverPubKey))
}
}
@@ -0,0 +1,52 @@
/*
* 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.nip96FileStorage.info
actual fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentiallyRelativeUrl: String,
): String =
try {
if (potentiallyRelativeUrl.contains("://")) {
potentiallyRelativeUrl
} else {
val base = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
if (potentiallyRelativeUrl.startsWith("/")) {
// Absolute path - combine with scheme + host from base
val schemeEnd = base.indexOf("://")
if (schemeEnd >= 0) {
val hostEnd = base.indexOf('/', schemeEnd + 3)
if (hostEnd >= 0) {
base.substring(0, hostEnd) + potentiallyRelativeUrl
} else {
base + potentiallyRelativeUrl.removePrefix("/")
}
} else {
potentiallyRelativeUrl
}
} else {
// Relative path
base + potentiallyRelativeUrl
}
}
} catch (e: Exception) {
potentiallyRelativeUrl
}
@@ -0,0 +1,77 @@
/*
* 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.utils
actual class BigDecimal actual constructor(
strVal: String,
) : Number() {
private val value: String = strVal.trim()
actual constructor(doubleVal: Double) : this(doubleVal.toBigDecimalString())
actual constructor(intVal: Int) : this(intVal.toString())
actual constructor(longVal: Long) : this(longVal.toString())
actual fun add(augend: BigDecimal): BigDecimal = BigDecimal((toDouble() + augend.toDouble()).toBigDecimalString())
actual fun subtract(subtrahend: BigDecimal): BigDecimal = BigDecimal((toDouble() - subtrahend.toDouble()).toBigDecimalString())
actual fun multiply(multiplicand: BigDecimal): BigDecimal = BigDecimal((toDouble() * multiplicand.toDouble()).toBigDecimalString())
actual fun divide(divisor: BigDecimal): BigDecimal = BigDecimal((toDouble() / divisor.toDouble()).toBigDecimalString())
actual fun negate(): BigDecimal = BigDecimal((-toDouble()).toBigDecimalString())
actual fun signum(): Int {
val d = toDouble()
return when {
d < 0.0 -> -1
d > 0.0 -> 1
else -> 0
}
}
override fun toInt(): Int = toDouble().toInt()
override fun toLong(): Long = toDouble().toLong()
override fun toShort(): Short = toDouble().toInt().toShort()
override fun toByte(): Byte = toDouble().toInt().toByte()
override fun toDouble(): Double = value.toDouble()
override fun toFloat(): Float = value.toFloat()
override fun equals(other: Any?): Boolean = (this === other) || (other is BigDecimal && value == other.value)
override fun hashCode(): Int = value.hashCode()
override fun toString(): String = value
}
private fun Double.toBigDecimalString(): String {
if (this == this.toLong().toDouble()) {
return this.toLong().toString()
}
return this.toString()
}
@@ -0,0 +1,136 @@
/*
* 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.utils
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import platform.zlib.Z_DEFAULT_COMPRESSION
import platform.zlib.Z_DEFAULT_STRATEGY
import platform.zlib.Z_DEFLATED
import platform.zlib.Z_FINISH
import platform.zlib.Z_NO_FLUSH
import platform.zlib.Z_OK
import platform.zlib.Z_STREAM_END
import platform.zlib.deflate
import platform.zlib.deflateBound
import platform.zlib.deflateEnd
import platform.zlib.deflateInit2
import platform.zlib.inflate
import platform.zlib.inflateEnd
import platform.zlib.inflateInit2
import platform.zlib.z_stream
@OptIn(ExperimentalForeignApi::class)
actual object GZip {
actual fun compress(content: String): ByteArray {
val input = content.encodeToByteArray()
return memScoped {
val stream = alloc<z_stream>()
deflateInit2(
stream.ptr,
Z_DEFAULT_COMPRESSION,
Z_DEFLATED,
31,
8,
Z_DEFAULT_STRATEGY,
).let { check(it == Z_OK) { "deflateInit2 failed: $it" } }
val maxSize = deflateBound(stream.ptr, input.size.toULong()).toInt()
val output = ByteArray(maxSize)
val written =
input.usePinned { pinIn ->
output.usePinned { pinOut ->
if (input.isNotEmpty()) {
stream.next_in = pinIn.addressOf(0).reinterpret()
}
stream.avail_in = input.size.toUInt()
if (output.isNotEmpty()) {
stream.next_out = pinOut.addressOf(0).reinterpret()
}
stream.avail_out = maxSize.toUInt()
deflate(stream.ptr, Z_FINISH)
.let { check(it == Z_STREAM_END) { "deflate failed: $it" } }
maxSize - stream.avail_out.toInt()
}
}
deflateEnd(stream.ptr)
output.copyOf(written)
}
}
actual fun decompress(content: ByteArray): String {
if (content.isEmpty()) return ""
val chunks = ArrayList<ByteArray>()
val chunkSize = maxOf(content.size * 4, 4096)
memScoped {
val stream = alloc<z_stream>()
inflateInit2(stream.ptr, 47)
.let { check(it == Z_OK) { "inflateInit2 failed: $it" } }
content.usePinned { pinIn ->
if (content.isNotEmpty()) {
stream.next_in = pinIn.addressOf(0).reinterpret()
}
stream.avail_in = content.size.toUInt()
var status: Int = Z_OK
do {
val chunk = ByteArray(chunkSize)
chunk.usePinned { pinOut ->
stream.next_out = pinOut.addressOf(0).reinterpret()
stream.avail_out = chunkSize.toUInt()
status = inflate(stream.ptr, Z_NO_FLUSH)
val produced = chunkSize - stream.avail_out.toInt()
if (produced > 0) chunks.add(chunk.copyOf(produced))
}
} while (status == Z_OK)
check(status == Z_STREAM_END) { "inflate failed: $status" }
}
inflateEnd(stream.ptr)
}
val totalSize = chunks.sumOf { it.size }
val result = ByteArray(totalSize)
var pos = 0
chunks.forEach { chunk ->
chunk.copyInto(result, pos)
pos += chunk.size
}
return result.decodeToString()
}
}
@@ -0,0 +1,61 @@
/*
* 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.utils
actual object Log {
actual fun w(
tag: String,
message: String,
throwable: Throwable?,
) {
if (throwable != null) {
println("WARN: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}")
} else {
println("WARN: [$tag] $message")
}
}
actual fun e(
tag: String,
message: String,
throwable: Throwable?,
) {
if (throwable != null) {
println("ERROR: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}")
} else {
println("ERROR: [$tag] $message")
}
}
actual fun d(
tag: String,
message: String,
) {
println("DEBUG: [$tag] $message")
}
actual fun i(
tag: String,
message: String,
) {
println("INFO: [$tag] $message")
}
}
@@ -0,0 +1,40 @@
/*
* 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.utils
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import platform.posix.CLOCK_REALTIME
import platform.posix.clock_gettime
import platform.posix.timespec
actual fun platform() = "Linux"
@OptIn(ExperimentalForeignApi::class)
actual fun currentTimeSeconds(): Long {
memScoped {
val ts = alloc<timespec>()
clock_gettime(CLOCK_REALTIME, ts.ptr)
return ts.tv_sec
}
}
@@ -0,0 +1,125 @@
/*
* 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.utils
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.posix.O_RDONLY
import platform.posix.close
import platform.posix.open
import platform.posix.read
actual class SecureRandom {
actual fun nextInt(): Int {
val bytes = ByteArray(4)
nextBytes(bytes)
return ((bytes[0].toInt() and 0xFF) shl 24) or
((bytes[1].toInt() and 0xFF) shl 16) or
((bytes[2].toInt() and 0xFF) shl 8) or
(bytes[3].toInt() and 0xFF)
}
actual fun nextInt(bound: Int): Int {
require(bound > 0) { throw IllegalArgumentException("Bad Bound $bound") }
var intValue = nextPositiveInt()
val m = bound - 1
if ((bound and m) == 0) {
intValue = ((bound * intValue.toLong()) shr 31).toInt()
} else {
var u: Int = intValue
intValue = u % bound
while (u - intValue + m < 0) {
u = nextPositiveInt()
intValue = u % bound
}
}
return intValue
}
actual fun nextLong(): Long {
val bytes = ByteArray(8)
nextBytes(bytes)
return ((bytes[0].toLong() and 0xFFL) shl 56) or
((bytes[1].toLong() and 0xFFL) shl 48) or
((bytes[2].toLong() and 0xFFL) shl 40) or
((bytes[3].toLong() and 0xFFL) shl 32) or
((bytes[4].toLong() and 0xFFL) shl 24) or
((bytes[5].toLong() and 0xFFL) shl 16) or
((bytes[6].toLong() and 0xFFL) shl 8) or
(bytes[7].toLong() and 0xFFL)
}
actual fun nextLong(bound: Long): Long {
require(bound > 0) { throw IllegalArgumentException("Bad Bound $bound") }
if (bound < Int.MAX_VALUE) {
return nextInt(bound.toInt()).toLong()
}
return nextPositiveLong() % bound
}
fun nextPositiveInt(): Int {
val value = nextInt()
return if (value > 0) {
value
} else {
-value
}
}
fun nextPositiveLong(): Long {
val value = nextLong()
return if (value > 0) {
value
} else {
-value
}
}
@OptIn(ExperimentalForeignApi::class)
actual fun nextBytes(output: ByteArray) {
if (output.isEmpty()) return
val fd = open("/dev/urandom", O_RDONLY)
if (fd == -1) {
throw IllegalStateException("Failed to open /dev/urandom")
}
try {
output.usePinned { pinned ->
var bytesRead = 0
while (bytesRead < output.size) {
val result = read(fd, pinned.addressOf(bytesRead), (output.size - bytesRead).convert())
if (result <= 0) {
throw IllegalStateException("Failed to read from /dev/urandom")
}
bytesRead += result.toInt()
}
}
} finally {
close(fd)
}
}
}
@@ -0,0 +1,30 @@
/*
* 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.utils
actual class UnicodeNormalizer {
actual fun normalizeNFKC(input: String): String {
// NFKC normalization on Linux native without ICU
// For most Nostr use cases (NIP-05, etc.) ASCII-safe content works without normalization.
// Full NFKC support would require linking against ICU or a pure-Kotlin normalizer.
return input
}
}
@@ -0,0 +1,161 @@
/*
* 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.utils
actual class UriParser actual constructor(
uri: String,
) {
private val parsedScheme: String?
private val parsedHost: String?
private val parsedPort: Int?
private val parsedPath: String?
private val parsedQuery: String?
private val parsedFragment: String?
init {
var remaining = uri
// Parse scheme
val schemeEnd = remaining.indexOf("://")
if (schemeEnd >= 0) {
parsedScheme = remaining.substring(0, schemeEnd)
remaining = remaining.substring(schemeEnd + 3)
} else {
parsedScheme = null
}
// Parse fragment
val fragmentStart = remaining.indexOf('#')
if (fragmentStart >= 0) {
parsedFragment = remaining.substring(fragmentStart + 1)
remaining = remaining.substring(0, fragmentStart)
} else {
parsedFragment = null
}
// Parse query
val queryStart = remaining.indexOf('?')
if (queryStart >= 0) {
parsedQuery = remaining.substring(queryStart + 1)
remaining = remaining.substring(0, queryStart)
} else {
parsedQuery = null
}
// Parse host and port
val pathStart = remaining.indexOf('/')
val authority =
if (pathStart >= 0) {
val auth = remaining.substring(0, pathStart)
remaining = remaining.substring(pathStart)
auth
} else {
val auth = remaining
remaining = ""
auth
}
// Remove userinfo
val atIndex = authority.lastIndexOf('@')
val hostPort = if (atIndex >= 0) authority.substring(atIndex + 1) else authority
// Handle IPv6
if (hostPort.startsWith("[")) {
val bracketEnd = hostPort.indexOf(']')
if (bracketEnd >= 0) {
parsedHost = hostPort.substring(1, bracketEnd)
val afterBracket = hostPort.substring(bracketEnd + 1)
parsedPort =
if (afterBracket.startsWith(":")) {
afterBracket.substring(1).toIntOrNull()
} else {
null
}
} else {
parsedHost = hostPort
parsedPort = null
}
} else {
val colonIndex = hostPort.lastIndexOf(':')
if (colonIndex >= 0) {
val potentialPort = hostPort.substring(colonIndex + 1).toIntOrNull()
if (potentialPort != null) {
parsedHost = hostPort.substring(0, colonIndex)
parsedPort = potentialPort
} else {
parsedHost = hostPort
parsedPort = null
}
} else {
parsedHost = hostPort.ifEmpty { null }
parsedPort = null
}
}
parsedPath = remaining.ifEmpty { null }
}
actual fun scheme(): String? = parsedScheme
actual fun host(): String? = parsedHost
actual fun port(): Int? = parsedPort
actual fun path(): String? = parsedPath
actual fun queryParameterNames(): Set<String> {
val query = parsedQuery ?: return emptySet()
return query
.split('&')
.mapNotNull { param ->
val eqIndex = param.indexOf('=')
if (eqIndex >= 0) param.substring(0, eqIndex) else param
}.toSet()
}
actual fun getQueryParameter(param: String): String? {
val query = parsedQuery ?: return null
return query
.split('&')
.firstOrNull { part ->
val eqIndex = part.indexOf('=')
if (eqIndex >= 0) part.substring(0, eqIndex) == param else part == param
}?.let { part ->
val eqIndex = part.indexOf('=')
if (eqIndex >= 0) part.substring(eqIndex + 1) else ""
}
}
val fragments: Map<String, String> by lazy {
parsedFragment?.ifBlank { null }?.let { keyValuePair ->
keyValuePair.split('&').associate { paramValue ->
val parts = paramValue.split("=", limit = 2)
if (parts.size == 2) {
parts[0] to parts[1]
} else {
parts[0] to ""
}
}
} ?: emptyMap()
}
actual fun fragments(): Map<String, String> = fragments
}
@@ -0,0 +1,29 @@
/*
* 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.utils
import net.thauvin.erik.urlencoder.UrlEncoderUtil
actual object UrlEncoder {
actual fun encode(value: String): String = UrlEncoderUtil.encode(value)
actual fun decode(value: String): String = UrlEncoderUtil.decode(value)
}
@@ -0,0 +1,319 @@
/*
* 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.utils.cache
import kotlin.concurrent.AtomicReference
actual class LargeCache<K, V> : ICacheOperations<K, V> {
private val mapRef = AtomicReference(LinkedHashMap<K, V>())
private inline fun <R> withMap(block: (LinkedHashMap<K, V>) -> R): R = block(mapRef.value)
private inline fun mutate(block: (LinkedHashMap<K, V>) -> Unit) {
val copy = LinkedHashMap(mapRef.value)
block(copy)
mapRef.value = copy
}
actual fun keys(): Set<K> = withMap { LinkedHashSet(it.keys) }
actual fun values(): Iterable<V> = withMap { ArrayList(it.values) }
actual fun get(key: K): V? = withMap { it[key] }
actual fun remove(key: K): V? {
val current = mapRef.value
val value = current[key]
if (value != null) {
mutate { it.remove(key) }
}
return value
}
actual fun isEmpty(): Boolean = withMap { it.isEmpty() }
actual fun clear() {
mapRef.value = LinkedHashMap()
}
actual fun containsKey(key: K): Boolean = withMap { it.containsKey(key) }
actual fun put(
key: K,
value: V,
) {
mutate { it[key] = value }
}
actual fun getOrCreate(
key: K,
builder: (K) -> V,
): V {
val existing = get(key)
if (existing != null) return existing
val newObject = builder(key)
mutate { it[key] = newObject }
return get(key) ?: newObject
}
actual fun createIfAbsent(
key: K,
builder: (K) -> V,
): Boolean {
val existing = get(key)
if (existing != null) return false
val newObject = builder(key)
mutate { it[key] = newObject }
return get(key) != null
}
actual override fun size(): Int = withMap { it.size }
actual override fun forEach(consumer: ICacheBiConsumer<K, V>) {
withMap { map -> map.forEach { consumer.accept(it.key, it.value) } }
}
actual override fun filter(consumer: CacheCollectors.BiFilter<K, V>): List<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toList() }
actual override fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toSet() }
actual override fun <R> map(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): List<R> = withMap { map -> map.map { consumer.map(it.key, it.value) } }
actual override fun <R> mapNotNull(consumer: CacheCollectors.BiMapper<K, V, R?>): List<R> = withMap { map -> map.mapNotNull { consumer.map(it.key, it.value) } }
actual override fun <R> mapNotNullIntoSet(consumer: CacheCollectors.BiMapper<K, V, R?>): Set<R> = mapNotNull(consumer).toSet()
actual override fun <R> mapFlatten(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): List<R> = withMap { map -> map.flatMap { entry -> consumer.map(entry.key, entry.value) ?: emptyList() } }
actual override fun <R> mapFlattenIntoSet(consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>): Set<R> = mapFlatten(consumer).toSet()
actual override fun maxOrNullOf(
filter: CacheCollectors.BiFilter<K, V>,
comparator: Comparator<V>,
): V? =
withMap { map ->
var maxV: V? = null
map.forEach {
if (filter.filter(it.key, it.value)) {
if (maxV == null || comparator.compare(it.value, maxV) > 0) {
maxV = it.value
}
}
}
maxV
}
actual override fun sumOf(consumer: CacheCollectors.BiSumOf<K, V>): Int =
withMap { map ->
var sum = 0
map.forEach { sum += consumer.map(it.key, it.value) }
sum
}
actual override fun sumOfLong(consumer: CacheCollectors.BiSumOfLong<K, V>): Long =
withMap { map ->
var sum = 0L
map.forEach { sum += consumer.map(it.key, it.value) }
sum
}
actual override fun <R> groupBy(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, List<V>> =
withMap { map ->
val results = HashMap<R, ArrayList<V>>()
map.forEach {
val group = consumer.map(it.key, it.value)
results.getOrPut(group) { ArrayList() }.add(it.value)
}
results
}
actual override fun <R> countByGroup(consumer: CacheCollectors.BiNotNullMapper<K, V, R>): Map<R, Int> =
withMap { map ->
val results = HashMap<R, Int>()
map.forEach {
val group = consumer.map(it.key, it.value)
results[group] = (results[group] ?: 0) + 1
}
results
}
actual override fun <R> sumByGroup(
groupMap: CacheCollectors.BiNotNullMapper<K, V, R>,
sumOf: CacheCollectors.BiNotNullMapper<K, V, Long>,
): Map<R, Long> =
withMap { map ->
val results = HashMap<R, Long>()
map.forEach {
val group = groupMap.map(it.key, it.value)
results[group] = (results[group] ?: 0L) + sumOf.map(it.key, it.value)
}
results
}
actual override fun count(consumer: CacheCollectors.BiFilter<K, V>): Int = withMap { map -> map.count { consumer.filter(it.key, it.value) } }
actual override fun <T, U> associate(transform: (K, V) -> Pair<T, U>): Map<T, U> =
withMap { map ->
val results = LinkedHashMap<T, U>(map.size)
map.forEach {
val pair = transform(it.key, it.value)
results[pair.first] = pair.second
}
results
}
actual override fun <U> associateWith(transform: (K, V) -> U?): Map<K, U?> =
withMap { map ->
val results = LinkedHashMap<K, U?>(map.size)
map.forEach {
results[it.key] = transform(it.key, it.value)
}
results
}
actual override fun filter(
from: K,
to: K,
consumer: CacheCollectors.BiFilter<K, V>,
): List<V> = filter(consumer)
actual override fun filterIntoSet(
from: K,
to: K,
consumer: CacheCollectors.BiFilter<K, V>,
): Set<V> = filterIntoSet(consumer)
actual override fun <R> map(
from: K,
to: K,
consumer: CacheCollectors.BiNotNullMapper<K, V, R>,
): List<R> = map(consumer)
actual override fun <R> mapNotNull(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, R?>,
): List<R> = mapNotNull(consumer)
actual override fun <R> mapNotNullIntoSet(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, R?>,
): Set<R> = mapNotNullIntoSet(consumer)
actual override fun <R> mapFlatten(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>,
): List<R> = mapFlatten(consumer)
actual override fun <R> mapFlattenIntoSet(
from: K,
to: K,
consumer: CacheCollectors.BiMapper<K, V, Collection<R>?>,
): Set<R> = mapFlattenIntoSet(consumer)
actual override fun maxOrNullOf(
from: K,
to: K,
filter: CacheCollectors.BiFilter<K, V>,
comparator: Comparator<V>,
): V? = maxOrNullOf(filter, comparator)
actual override fun sumOf(
from: K,
to: K,
consumer: CacheCollectors.BiSumOf<K, V>,
): Int = sumOf(consumer)
actual override fun sumOfLong(
from: K,
to: K,
consumer: CacheCollectors.BiSumOfLong<K, V>,
): Long = sumOfLong(consumer)
actual override fun <R> groupBy(
from: K,
to: K,
consumer: CacheCollectors.BiNotNullMapper<K, V, R>,
): Map<R, List<V>> = groupBy(consumer)
actual override fun <R> countByGroup(
from: K,
to: K,
consumer: CacheCollectors.BiNotNullMapper<K, V, R>,
): Map<R, Int> = countByGroup(consumer)
actual override fun <R> sumByGroup(
from: K,
to: K,
groupMap: CacheCollectors.BiNotNullMapper<K, V, R>,
sumOf: CacheCollectors.BiNotNullMapper<K, V, Long>,
): Map<R, Long> = sumByGroup(groupMap, sumOf)
actual override fun count(
from: K,
to: K,
consumer: CacheCollectors.BiFilter<K, V>,
): Int = count(consumer)
actual override fun <T, U> associate(
from: K,
to: K,
transform: (K, V) -> Pair<T, U>,
): Map<T, U> = associate(transform)
actual override fun <U> associateWith(
from: K,
to: K,
transform: (K, V) -> U?,
): Map<K, U?> = associateWith(transform)
actual override fun joinToString(
separator: CharSequence,
prefix: CharSequence,
postfix: CharSequence,
limit: Int,
truncated: CharSequence,
transform: ((K, V) -> CharSequence)?,
): String {
val buffer = StringBuilder()
buffer.append(prefix)
var count = 0
forEach { key, value ->
val str = if (transform != null) transform(key, value) else ""
if (str.isNotEmpty()) {
if (++count > 1) buffer.append(separator)
if (limit < 0 || count <= limit) {
when {
transform != null -> buffer.append(str)
else -> buffer.append("$key $value")
}
} else {
return@forEach
}
}
}
if (limit >= 0 && count > limit) buffer.append(truncated)
buffer.append(postfix)
return buffer.toString()
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.ciphers
import com.vitorpamplona.quartz.utils.Log
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.DelicateCryptographyApi
import dev.whyoleg.cryptography.algorithms.AES
actual class AESCBC actual constructor(
actual val keyBytes: ByteArray,
actual val iv: ByteArray,
) : NostrCipher {
private val cryptoProvider = CryptographyProvider.Default
private val aesCbc = cryptoProvider.get(AES.CBC)
private val keyDecoder =
aesCbc
.keyDecoder()
.decodeFromByteArrayBlocking(format = AES.Key.Format.RAW, keyBytes)
private fun cipher() = keyDecoder.cipher()
actual override fun name(): String = "aes-cbc"
@OptIn(DelicateCryptographyApi::class)
actual override fun encrypt(bytesToEncrypt: ByteArray): ByteArray =
with(cipher()) {
encryptWithIvBlocking(iv, bytesToEncrypt)
}
@OptIn(DelicateCryptographyApi::class)
actual override fun decrypt(bytesToDecrypt: ByteArray): ByteArray =
with(cipher()) {
decryptWithIvBlocking(iv, bytesToDecrypt)
}
@OptIn(DelicateCryptographyApi::class)
actual override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? =
try {
with(cipher()) {
decryptWithIvBlocking(iv, bytesToDecrypt)
}
} catch (e: Exception) {
Log.w("AESCBC", "Failed to decrypt", e)
null
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.ciphers
import com.vitorpamplona.quartz.utils.Log
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.DelicateCryptographyApi
import dev.whyoleg.cryptography.algorithms.AES
actual class AESGCM actual constructor(
actual val keyBytes: ByteArray,
actual val nonce: ByteArray,
) : NostrCipher {
private val provider = CryptographyProvider.Default
private val aesGcm = provider.get(AES.GCM)
private val keyDecoder =
aesGcm
.keyDecoder()
.decodeFromByteArrayBlocking(AES.Key.Format.RAW, keyBytes)
private fun cipher() = keyDecoder.cipher()
actual override fun name(): String = "aes-gcm"
@OptIn(DelicateCryptographyApi::class)
actual override fun encrypt(bytesToEncrypt: ByteArray): ByteArray =
with(cipher()) {
encryptWithIvBlocking(nonce, bytesToEncrypt)
}
@OptIn(DelicateCryptographyApi::class)
actual override fun decrypt(bytesToDecrypt: ByteArray): ByteArray =
with(cipher()) {
decryptWithIvBlocking(nonce, bytesToDecrypt)
}
@OptIn(DelicateCryptographyApi::class)
actual override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? =
try {
with(cipher()) {
decryptWithIvBlocking(nonce, bytesToDecrypt)
}
} catch (e: Exception) {
Log.w("AESGCM", "Failed to decrypt", e)
null
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.diggest
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.DelicateCryptographyApi
import dev.whyoleg.cryptography.algorithms.RIPEMD160
import dev.whyoleg.cryptography.algorithms.SHA1
import dev.whyoleg.cryptography.algorithms.SHA256
actual class DigestInstance actual constructor(
algorithm: String,
) {
private val cryptoProvider = CryptographyProvider.Default
private val hasher = cryptoProvider.get(digestForAlgorithm(algorithm)).hasher()
private val hashFunction = hasher.createHashFunction()
actual fun update(array: ByteArray) {
hashFunction.update(array)
}
actual fun update(byte: Byte) {
hashFunction.update(byteArrayOf(byte))
}
actual fun digest(): ByteArray = hashFunction.hashToByteArray()
actual fun digest(input: ByteArray): ByteArray = hasher.hashBlocking(input)
@OptIn(DelicateCryptographyApi::class)
private fun digestForAlgorithm(algorithmName: String) =
when (algorithmName) {
"SHA-256" -> SHA256
"SHA-1" -> SHA1
"ripemd160" -> RIPEMD160
"keccak256" -> error("KECCAK-256 is not yet supported.")
else -> error("This message digest is not supported.")
}
}
@@ -0,0 +1,65 @@
/*
* 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.utils.mac
import io.github.andreypfau.kotlinx.crypto.HMac
import io.github.andreypfau.kotlinx.crypto.Sha256
import io.github.andreypfau.kotlinx.crypto.Sha512
actual class MacInstance actual constructor(
algorithm: String,
key: ByteArray,
) {
private var nativeHmac = HMac(digestForAlgorithm(algorithm), key)
actual fun init(
key: ByteArray,
algorithm: String,
) {
nativeHmac = HMac(digestForAlgorithm(algorithm), key)
}
actual fun getMacLength(): Int = nativeHmac.macSize
actual fun update(array: ByteArray) {
nativeHmac.update(array)
}
actual fun update(byte: Byte) {
nativeHmac.update(byte)
}
actual fun doFinal(): ByteArray = nativeHmac.digest()
actual fun doFinal(
output: ByteArray,
offset: Int,
) {
nativeHmac.digest(output, offset)
}
private fun digestForAlgorithm(algorithm: String) =
when (algorithm) {
"HmacSHA256" -> Sha256()
"HmacSHA512" -> Sha512()
else -> error("Algorithm is not yet supported.")
}
}
@@ -0,0 +1,30 @@
/*
* 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.utils.sha256
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.algorithms.SHA256
actual fun sha256(data: ByteArray): ByteArray {
val provider = CryptographyProvider.Default
val hasher = provider.get(SHA256).hasher()
return hasher.hashBlocking(data)
}
@@ -0,0 +1,28 @@
/*
* 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.utils
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
actual fun platform() = "macOS"
actual fun currentTimeSeconds(): Long = (NSDate().timeIntervalSince1970).toLong()
@@ -45,7 +45,6 @@ actual object EventHasherSerializer {
add(kind) add(kind)
addJsonArray { addJsonArray {
tags.fastForEach { tag -> tags.fastForEach { tag ->
// add(JsonArray(content = tag.map { JsonPrimitive(it) }))
addJsonArray { tag.fastForEach { add(it) } } addJsonArray { tag.fastForEach { add(it) } }
} }
} }
@@ -68,10 +67,7 @@ actual object EventHasherSerializer {
kind: Int, kind: Int,
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
): ByteArray { ): ByteArray = makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray()
// Also use makeJsonObjectForId(...) above. May change this if needed(for performance).
return makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray()
}
actual fun makeJsonForIdHashAndCheck( actual fun makeJsonForIdHashAndCheck(
id: HexKey, id: HexKey,
@@ -81,7 +77,6 @@ actual object EventHasherSerializer {
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
): Boolean { ): Boolean {
// Also use makeJsonObjectForId(...) above. May change byteArray encode method if needed.
val eventIdHash = sha256(makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray()) val eventIdHash = sha256(makeJsonObjectForId(pubKey, createdAt, kind, tags, content).toString().encodeToByteArray())
return Hex.isEqual(id, eventIdHash) return Hex.isEqual(id, eventIdHash)
} }