From 43dc53b80d587f403e1ad71812dfffed6baa05e2 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Wed, 7 Feb 2024 16:20:03 -0300 Subject: [PATCH] database, REQ, EVENT, CLOSE, EOSE --- .idea/deploymentTargetDropDown.xml | 13 +- app/build.gradle.kts | 10 +- .../com/greenart7c3/citrine/CommandResult.kt | 19 +++ .../citrine/CustomWebSocketServer.kt | 106 ++++++++++++- .../main/java/com/greenart7c3/citrine/EOSE.kt | 5 + .../com/greenart7c3/citrine/EventFilter.kt | 98 ++++++++++++ .../greenart7c3/citrine/EventSubscription.kt | 94 +++++++++++ .../com/greenart7c3/citrine/MainActivity.kt | 2 + .../com/greenart7c3/citrine/NoticeResult.kt | 11 ++ .../citrine/WebSocketServerService.kt | 10 +- .../citrine/database/AppDatabase.kt | 34 ++++ .../greenart7c3/citrine/database/EventDao.kt | 68 ++++++++ .../citrine/database/EventEntity.kt | 147 ++++++++++++++++++ build.gradle.kts | 1 + settings.gradle.kts | 1 + 15 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/citrine/CommandResult.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/EOSE.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/EventFilter.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/EventSubscription.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/NoticeResult.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/database/AppDatabase.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/database/EventDao.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/database/EventEntity.kt diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 05c16d6..fa70127 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -4,6 +4,17 @@ + + + + + + + + + + + @@ -15,7 +26,7 @@ - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6768279..66123ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") } android { @@ -75,5 +76,12 @@ dependencies { implementation("io.ktor:ktor-server-cio:2.3.8") implementation("io.ktor:ktor-server-websockets:2.3.8") implementation("io.ktor:ktor-websockets:2.3.8") - + implementation("com.github.vitorpamplona.amethyst:quartz:v0.83.9") { + exclude("net.java.dev.jna") + } + implementation("net.java.dev.jna:jna:5.14.0@aar") + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + annotationProcessor("androidx.room:room-compiler:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") } \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/CommandResult.kt b/app/src/main/java/com/greenart7c3/citrine/CommandResult.kt new file mode 100644 index 0000000..17eeefe --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/CommandResult.kt @@ -0,0 +1,19 @@ +package com.greenart7c3.citrine + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.quartz.events.Event + +data class CommandResult(val eventId: String, val result: Boolean, val description: String = "") { + fun toJson(): String { + return jacksonObjectMapper().writeValueAsString( + listOf("OK", eventId, result, description) + ) + } + + companion object { + fun ok(event: Event) = CommandResult(event.id, true) + fun duplicated(event: Event) = CommandResult(event.id, true, "duplicate:") + fun invalid(event: Event, message: String) = CommandResult(event.id, false, "invalid: $message") + fun error(event: Event, message: String) = CommandResult(event.id, false, "error: $message") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/CustomWebSocketServer.kt b/app/src/main/java/com/greenart7c3/citrine/CustomWebSocketServer.kt index d2a0763..2015add 100644 --- a/app/src/main/java/com/greenart7c3/citrine/CustomWebSocketServer.kt +++ b/app/src/main/java/com/greenart7c3/citrine/CustomWebSocketServer.kt @@ -1,23 +1,47 @@ package com.greenart7c3.citrine +import EOSE +import android.content.Context +import android.util.Log +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.greenart7c3.citrine.database.AppDatabase +import com.greenart7c3.citrine.database.toEvent +import com.greenart7c3.citrine.database.toEventWithTags +import com.vitorpamplona.quartz.events.Event import io.ktor.http.ContentType import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.ApplicationEngine import io.ktor.server.engine.embeddedServer +import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.routing +import io.ktor.server.websocket.DefaultWebSocketServerSession import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.pingPeriod +import io.ktor.server.websocket.timeout import io.ktor.server.websocket.webSocket +import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketDeflateExtension +import io.ktor.websocket.close import io.ktor.websocket.readText +import io.ktor.websocket.send import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.time.Duration +import java.util.zip.Deflater -class CustomWebSocketServer(private val port: Int) { +class CustomWebSocketServer(private val port: Int, private val context: Context) { private lateinit var server: ApplicationEngine + private val objectMapper = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) fun port(): Int { return server.environment.connectors.first().port @@ -31,9 +55,77 @@ class CustomWebSocketServer(private val port: Int) { server.stop(1000) } + private suspend fun subscribe(subscriptionId: String, filterNodes: List, session: DefaultWebSocketServerSession) { + val filters = filterNodes.map { jsonNode -> + val tags = jsonNode.fields().asSequence() + .filter { it.key.startsWith("#") } + .map { it.key.substringAfter("#") to it.value.map { item -> item.asText() }.toSet() } + .toMap() + + val filter = objectMapper.treeToValue(jsonNode, EventFilter::class.java) + + filter.copy(tags = tags) + }.toSet() + + for (filter in filters) { + runBlocking { + EventSubscription.subscribe(subscriptionId, filter, session, context, objectMapper, true) + } + } + + session.send(EOSE(subscriptionId).toJson()) + } + + + private suspend fun processNewRelayMessage(newMessage: String, session: DefaultWebSocketServerSession) { + val msgArray = Event.mapper.readTree(newMessage) + when (val type = msgArray.get(0).asText()) { + "REQ" -> { + val subscriptionId = msgArray.get(1).asText() + subscribe(subscriptionId, msgArray.drop(2), session) + } + "EVENT" -> { + processEvent(msgArray.get(1), session) + } + "CLOSE" -> { + session.close(CloseReason(CloseReason.Codes.NORMAL, newMessage)) + } + "PING" -> { + session.send(NoticeResult("PONG").toJson()) + } + else -> { + val errorMessage = NoticeResult.invalid("unknown message type $type").toJson() + Log.d("message", errorMessage) + session.send(errorMessage) + } + } + } + + private suspend fun processEvent(eventNode: JsonNode, session: DefaultWebSocketServerSession) { + val event = objectMapper.treeToValue(eventNode, Event::class.java) + + AppDatabase.getDatabase(context).eventDao().insertEventWithTags(event.toEventWithTags()) + + session.send(CommandResult.ok(event).toJson()) + } + private fun startKtorHttpServer(port: Int): ApplicationEngine { return embeddedServer(CIO, port = port) { - install(WebSockets) + install(WebSockets) { + extensions { + install(WebSocketDeflateExtension) { + /** + * Compression level to use for [java.util.zip.Deflater]. + */ + compressionLevel = Deflater.DEFAULT_COMPRESSION + + /** + * Prevent compressing small outgoing frames. + */ + compressIfBiggerThan(bytes = 4 * 1024) + } + } + } routing { // Handle HTTP GET requests @@ -54,7 +146,6 @@ class CustomWebSocketServer(private val port: Int) { } else { call.respondText("Use a Nostr client or Websocket client to connect", ContentType.Text.Html) } - } // WebSocket endpoint @@ -63,13 +154,16 @@ class CustomWebSocketServer(private val port: Int) { for (frame in incoming) { when (frame) { is Frame.Text -> { - println("Received WebSocket message: ${frame.readText()}") + val message = frame.readText() + processNewRelayMessage(message, this) + } + else -> { + Log.d("error", frame.toString()) } - else -> {} } } } catch (e: ClosedReceiveChannelException) { - // Channel closed + Log.d("error", e.toString()) } } } diff --git a/app/src/main/java/com/greenart7c3/citrine/EOSE.kt b/app/src/main/java/com/greenart7c3/citrine/EOSE.kt new file mode 100644 index 0000000..25a31b7 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/EOSE.kt @@ -0,0 +1,5 @@ +data class EOSE(val subscriptionId: String) { + fun toJson(): String { + return """["EOSE","$subscriptionId"]""" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/EventFilter.kt b/app/src/main/java/com/greenart7c3/citrine/EventFilter.kt new file mode 100644 index 0000000..6017741 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/EventFilter.kt @@ -0,0 +1,98 @@ +package com.greenart7c3.citrine + +/** + * Copyright (c) 2023 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. + */ + +import com.vitorpamplona.quartz.events.Event +import java.util.function.Predicate + +data class EventFilter( + val ids: Set = emptySet(), + val authors: Set = emptySet(), + val kinds: Set = emptySet(), + val tags: Map> = emptyMap(), + val since: Int? = null, + val until: Int? = null, + val limit: Int = 10_000, + private val search: String? = null +) : Predicate { + + val searchKeywords: Set = search?.let { tokenizeString(search) } ?: emptySet() + + override fun test(event: Event): Boolean { + if (since != null && event.createdAt < since) { + return false + } + + if (until != null && event.createdAt > until) { + return false + } + + if (ids.isNotEmpty() && ids.none { event.id.startsWith(it) }) { + return false + } + + if (authors.isNotEmpty() && authors.none { event.pubKey.startsWith(it) }) { + return false + } + + if (kinds.isNotEmpty() && event.kind !in kinds) { + return false + } + + if (tags.isNotEmpty() && tags.none { testTag(it, event) }) { + return false + } + + if (!search.isNullOrBlank() && !testSearch(search, event)) { + return false + } + + return true + } + + private fun testTag(tag: Map.Entry>, event: Event): Boolean { + val eventTags: Set = event.tags.asSequence() + .filter { it.size > 1 && it[0] == tag.key } + .map { it[1] } + .toSet() + + return tag.value.any { it in eventTags } + } + + private fun testSearch(search: String, event: Event): Boolean { + val tokens = tokenizeString(search) + val eventTokens = tokenizeString(event.content) + + return tokens.all { it in eventTokens } + } + + private fun tokenizeString(string: String): Set { + return string.split(TOKENIZE_REGEX) + .filter { it.isNotEmpty() } + .map { it.lowercase() } + .toSet() + } + + companion object { + val TOKENIZE_REGEX = "[^a-zA-Z0-9]".toRegex() + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/EventSubscription.kt b/app/src/main/java/com/greenart7c3/citrine/EventSubscription.kt new file mode 100644 index 0000000..86c1ad8 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/EventSubscription.kt @@ -0,0 +1,94 @@ +package com.greenart7c3.citrine + +import EOSE +import android.content.Context +import android.util.Log +import com.fasterxml.jackson.databind.ObjectMapper +import com.greenart7c3.citrine.database.AppDatabase +import com.greenart7c3.citrine.database.toEvent +import io.ktor.server.websocket.DefaultWebSocketServerSession +import io.ktor.websocket.send +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +object EventSubscription { + suspend fun subscribe(subscriptionId: String, filter: EventFilter, session: DefaultWebSocketServerSession, context: Context, objectMapper: ObjectMapper, isLast: Boolean) { + val whereClause = mutableListOf() + val parameters = mutableListOf() + + if (filter.since != null) { + whereClause.add("EventEntity.createdAt >= ${filter.since}") + } + + if (filter.until != null) { + whereClause.add("EventEntity.createdAt <= ${filter.until}") + } + + if (filter.ids.isNotEmpty()) { + whereClause.add( + filter.ids.joinToString(" OR ", prefix = "(", postfix = ")") { + "EventEntity.id = '${it}'" + } + ) + } + + if (filter.authors.isNotEmpty()) { + whereClause.add( + filter.authors.joinToString(" OR ", prefix = "(", postfix = ")") { + "EventEntity.pubkey = '${it}'" + } + ) + } + + if (filter.searchKeywords.isNotEmpty()) { + whereClause.add( + filter.searchKeywords.joinToString(" AND ", prefix = "(", postfix = ")") { "EventEntity.content ~* ?" } + ) + + parameters.addAll(filter.searchKeywords.map { it }) + } + + if (filter.kinds.isNotEmpty()) { + whereClause.add("EventEntity.kind IN (${filter.kinds.joinToString(",")})") + } + + if (filter.tags.isNotEmpty()) { + filter.tags.filterValues { it.isNotEmpty() }.forEach { tag -> + whereClause.add( + "TagEntity.col0Name = '${tag.key}' AND TagEntity.col1Value = '${tag.value.toString().removePrefix("[").removeSuffix("]")}'" + ) + } + } + + val predicatesSql = whereClause.joinToString(" AND ", prefix = "WHERE ") + + var query = """ + SELECT EventEntity.pk + FROM EventEntity EventEntity + LEFT JOIN TagEntity TagEntity ON EventEntity.pk = TagEntity.pkEvent + $predicatesSql + ORDER BY EventEntity.createdAt DESC + """ + + Log.d("query", query) + + if (filter.limit > 0) { + query += " LIMIT ${filter.limit}" + } + + val cursor = AppDatabase.getDatabase(context).query(query, parameters.toTypedArray()) + cursor.use { item -> + while (item.moveToNext()) { + val eventEntity = AppDatabase.getDatabase(context).eventDao().getById(item.getString(0)) + runBlocking { + session.send( + objectMapper.writeValueAsString( + listOf("EVENT", subscriptionId, eventEntity.toEvent().toJsonObject()) + ), + ) + } + } + } + delay(1000) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/MainActivity.kt b/app/src/main/java/com/greenart7c3/citrine/MainActivity.kt index 41be13f..2fab6e6 100644 --- a/app/src/main/java/com/greenart7c3/citrine/MainActivity.kt +++ b/app/src/main/java/com/greenart7c3/citrine/MainActivity.kt @@ -6,6 +6,8 @@ import android.content.Intent import android.content.ServiceConnection import android.os.Bundle import android.os.IBinder +import android.os.StrictMode +import android.os.StrictMode.VmPolicy import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts diff --git a/app/src/main/java/com/greenart7c3/citrine/NoticeResult.kt b/app/src/main/java/com/greenart7c3/citrine/NoticeResult.kt new file mode 100644 index 0000000..6397d66 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/NoticeResult.kt @@ -0,0 +1,11 @@ +package com.greenart7c3.citrine + +data class NoticeResult(val message: String) { + fun toJson(): String { + return """["NOTICE","$message"]""" + } + + companion object { + fun invalid(message: String) = NoticeResult("invalid: $message") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/WebSocketServerService.kt b/app/src/main/java/com/greenart7c3/citrine/WebSocketServerService.kt index e2db556..77d203f 100644 --- a/app/src/main/java/com/greenart7c3/citrine/WebSocketServerService.kt +++ b/app/src/main/java/com/greenart7c3/citrine/WebSocketServerService.kt @@ -13,6 +13,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Binder +import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat.getSystemService @@ -41,10 +42,15 @@ class WebSocketServerService : Service() { super.onCreate() val intentFilter = IntentFilter("com.example.ACTION_COPY") - registerReceiver(brCopy, intentFilter) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(brCopy, intentFilter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(brCopy, intentFilter) + } // Start the WebSocket server - webSocketServer = CustomWebSocketServer(7777) + webSocketServer = CustomWebSocketServer(7777, this@WebSocketServerService) webSocketServer.start() // Create a notification to keep the service in the foreground diff --git a/app/src/main/java/com/greenart7c3/citrine/database/AppDatabase.kt b/app/src/main/java/com/greenart7c3/citrine/database/AppDatabase.kt new file mode 100644 index 0000000..02c2ad0 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/database/AppDatabase.kt @@ -0,0 +1,34 @@ +package com.greenart7c3.citrine.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +@Database( + entities = [EventEntity::class, TagEntity::class], + version = 1 +) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun eventDao(): EventDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context, + AppDatabase::class.java, + "citrine_database" + ).build() + + INSTANCE = instance + instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/database/EventDao.kt b/app/src/main/java/com/greenart7c3/citrine/database/EventDao.kt new file mode 100644 index 0000000..ac178f5 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/database/EventDao.kt @@ -0,0 +1,68 @@ +package com.greenart7c3.citrine.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction + +@Dao +interface EventDao { + @Query("SELECT * FROM EventEntity ORDER BY createdAt DESC") + @Transaction + fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertEvent(event: EventEntity): Long? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertTags(tags: List): List? + + @Query("SELECT * FROM EventEntity WHERE pk = :id") + @Transaction + fun getById(id: String): EventWithTags + + @Insert(onConflict = OnConflictStrategy.IGNORE) + @Transaction + fun insertEventWithTags(dbEvent: EventEntity, dbTags: List) { + insertEvent(dbEvent)?.let { eventPK -> + if (eventPK >= 0) { + dbTags.forEach { + it.pkEvent = eventPK + } + + insertTags(dbTags) + } + } + } + + @Insert(onConflict = OnConflictStrategy.IGNORE) + @Transaction + fun insertEventWithTags(dbEvent: EventWithTags) { + insertEvent(dbEvent.event)?.let { eventPK -> + if (eventPK >= 0) { + dbEvent.tags.forEach { + it.pkEvent = eventPK + } + + insertTags(dbEvent.tags) + } + } + } + + @Insert(onConflict = OnConflictStrategy.IGNORE) + @Transaction + fun insertListOfEventWithTags(dbEvent: List) { + dbEvent.forEach { + insertEvent(it.event)?.let { eventPK -> + if (eventPK >= 0) { + it.tags.forEach { + it.pkEvent = eventPK + } + + insertTags(it.tags) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/citrine/database/EventEntity.kt b/app/src/main/java/com/greenart7c3/citrine/database/EventEntity.kt new file mode 100644 index 0000000..aeaeb57 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/database/EventEntity.kt @@ -0,0 +1,147 @@ +package com.greenart7c3.citrine.database + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.Companion.CASCADE +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import androidx.room.TypeConverter +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventFactory + +@Entity( + indices = [ + Index( + value = ["id"], + name = "id_is_hash", + unique = true + ), + Index( + value = ["pubkey", "kind"], + name = "most_common_search_is_pubkey_kind", + orders = [Index.Order.ASC, Index.Order.ASC] + ) + ] +) +data class EventEntity( + @PrimaryKey(autoGenerate = true) val pk: Long? = null, + val id: String, + val pubkey: String, + val createdAt: Long, + val kind: Int, + val content: String, + val sig: String +) + +data class EventWithTags( + @Embedded val event: EventEntity, + @Relation( + parentColumn = "pk", + entityColumn = "pkEvent" + ) + val tags: List +) + +@Entity( + foreignKeys = [ + ForeignKey( + entity = EventEntity::class, + childColumns = ["pkEvent"], + parentColumns = ["pk"], + onDelete = CASCADE + ) + ], + indices = [ + Index( + value = ["pkEvent"], + name = "tags_by_pk_event" + ), + Index( + value = ["col0Name", "col1Value"], + name = "tags_by_tags_on_person_or_events" + ) + ] +) + +data class TagEntity( + @PrimaryKey(autoGenerate = true) val pk: Long? = null, + + var pkEvent: Long? = null, + val position: Int, + + // Holds 6 columns but can be extended. + val col0Name: String?, + val col1Value: String?, + val col2Differentiator: String?, + val col3Amount: String?, + val col4Plus: List +) + +class Converters { + val mapper = jacksonObjectMapper() + + @TypeConverter + fun fromString(value: String?): List { + if (value == null) return emptyList() + if (value == "") return emptyList() + return mapper.readValue(value) + } + + @TypeConverter + fun fromList(list: List?): String { + if (list == null) return "" + if (list.isEmpty()) return "" + return mapper.writeValueAsString(list) + } +} + +fun EventWithTags.toEvent(): Event { + return EventFactory.create( + id = event.id, + pubKey = event.pubkey, + createdAt = event.createdAt, + kind = event.kind, + content = event.content, + sig = event.sig, + tags = tags.map { + it.toTags() + }.toTypedArray() + ) +} + +fun TagEntity.toTags(): Array { + return listOfNotNull( + col0Name, + col1Value, + col2Differentiator, + col3Amount + ).plus(col4Plus).toTypedArray() +} + +fun Event.toEventWithTags(): EventWithTags { + val dbEvent = EventEntity( + id = id, + pubkey = pubKey, + createdAt = createdAt, + kind = kind, + content = content, + sig = sig + ) + + val dbTags = tags.mapIndexed { index, tag -> + TagEntity( + position = index, + col0Name = tag.getOrNull(0), // tag name + col1Value = tag.getOrNull(1), // tag value + col2Differentiator = tag.getOrNull(2), // marker + col3Amount = tag.getOrNull(3), // value + col4Plus = if (tag.size > 4) tag.asList().subList(4, tag.size) else emptyList() + ) + } + + return EventWithTags(dbEvent, dbTags) +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 53f4a67..a872837 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.2.2" apply false id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 13b264f..158c24b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { setUrl("https://jitpack.io") } } }