Reverts to the non-Google kotlin style.

This commit is contained in:
Vitor Pamplona
2024-01-06 11:32:41 -05:00
parent 0be1f89368
commit f8e7dd78d9
415 changed files with 64333 additions and 64218 deletions
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import java.util.Base64
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
@@ -36,107 +35,114 @@ import kotlinx.coroutines.runBlocking
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.Base64
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ImageUploadTesting { class ImageUploadTesting {
val contentType = "image/gif" val contentType = "image/gif"
val image = val image =
"R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
val contentTypePng = "image/png" val contentTypePng = "image/png"
val imagePng = val imagePng =
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
private suspend fun testBase(server: Nip96MediaServers.ServerName) { private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo = val serverInfo =
Nip96Retriever() Nip96Retriever()
.loadInfo( .loadInfo(
server.baseUrl, server.baseUrl,
)
val bytes = Base64.getDecoder().decode(imagePng)
val inputStream = bytes.inputStream()
val account = Account(KeyPair())
val result =
Nip96Uploader(account)
.uploadImage(
inputStream,
bytes.size.toLong(),
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {},
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null }
val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null }
val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null }
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
Assert.assertTrue(url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
?: run {
fail("Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
}
if (hash != null) {
assertEquals(hash, it.hash)
}
},
onError = { fail("It should not fail") },
) )
val bytes = Base64.getDecoder().decode(imagePng) // delay(1000)
val inputStream = bytes.inputStream()
val account = Account(KeyPair()) // assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
}
val result = @Test()
Nip96Uploader(account) fun testNostrCheck() =
.uploadImage( runBlocking {
inputStream, testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
bytes.size.toLong(),
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {},
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null }
val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null }
val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null }
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
Assert.assertTrue(url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
?: run {
fail("Should not be null")
return
} }
FileHeader.prepare( @Test()
imageData, fun testNostrage() =
contentTypePng, runBlocking {
null, testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
} }
if (size != null) {
assertEquals(size, it.size.toString()) @Test()
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
} }
if (hash != null) {
assertEquals(hash, it.hash) @Test()
fun testNostrBuild() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
} }
},
onError = { fail("It should not fail") },
)
// delay(1000) @Test()
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo)) @Test()
} fun testVoidCat() =
runBlocking {
@Test() testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
fun testNostrCheck() = runBlocking { }
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
}
@Test()
fun testNostrage() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
fun testSove() = runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
}
@Test()
fun testNostrBuild() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
@Test()
fun testSovbit() = runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
@Test()
fun testVoidCat() = runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -40,92 +40,92 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class UrlUserTagTransformationTest { class UrlUserTagTransformationTest {
@Test @Test
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
} }
@Test @Test
fun transformationText() { fun transformationText() {
val user = val user =
LocalCache.getOrCreateUser( LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(), .toHexKey(),
) )
user.info = UserMetadata() user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona" user.info?.displayName = "Vitor Pamplona"
val transformedText = val transformedText =
buildAnnotatedStringWithUrlHighlighting( buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red, Color.Red,
) )
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
} }
@Test @Test
fun transformationTextTwoKeys() { fun transformationTextTwoKeys() {
val user = val user =
LocalCache.getOrCreateUser( LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(), .toHexKey(),
) )
user.info = UserMetadata() user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona" user.info?.displayName = "Vitor Pamplona"
val transformedText = val transformedText =
buildAnnotatedStringWithUrlHighlighting( buildAnnotatedStringWithUrlHighlighting(
AnnotatedString( AnnotatedString(
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", "New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
), ),
Color.Red, Color.Red,
) )
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space> assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space> assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space> assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space> assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
} }
} }
@@ -30,136 +30,136 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TranslationsTest { class TranslationsTest {
fun translateTo( fun translateTo(
text: String, text: String,
translateTo: String, translateTo: String,
): String? { ): String? {
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
return Tasks.await(task).result return Tasks.await(task).result
} }
fun assertTranslate( fun assertTranslate(
expected: String, expected: String,
input: String, input: String,
translateTo: String, translateTo: String,
) { ) {
assertEquals(null, expected, translateTo(input, translateTo)) assertEquals(null, expected, translateTo(input, translateTo))
} }
fun assertTranslateContains( fun assertTranslateContains(
expected: String, expected: String,
input: String, input: String,
translateTo: String, translateTo: String,
) { ) {
val translated = translateTo(input, translateTo)!! val translated = translateTo(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
} }
@Test @Test
fun testTranslation() { fun testTranslation() {
assertTranslate("Olá mundo", "Hello World", "pt") assertTranslate("Olá mundo", "Hello World", "pt")
} }
@Test @Test
fun testTranslationName() { fun testTranslationName() {
assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt") assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt")
} }
@Test @Test
fun testTranslationTag() { fun testTranslationTag() {
assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt") assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt")
} }
@Test @Test
fun testTranslationUrl() { fun testTranslationUrl() {
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
assertTranslateContains( assertTranslateContains(
"http://bananas.com/myimage.jpg", "http://bananas.com/myimage.jpg",
"Have you seen this http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg",
"pt", "pt",
) )
assertTranslateContains( assertTranslateContains(
"http://bananas.com?search=true&image=myimage.jpg", "http://bananas.com?search=true&image=myimage.jpg",
"Have you seen this http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg",
"pt", "pt",
) )
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
assertTranslate( assertTranslate(
"https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg",
"https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg",
"pt", "pt",
) )
assertTranslateContains( assertTranslateContains(
"https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg",
"Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg",
"pt", "pt",
) )
} }
@Test @Test
fun testChineseWithUrlDetector() { fun testChineseWithUrlDetector() {
assertTranslate( assertTranslate(
"I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client",
"我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端",
"en", "en",
) )
} }
@Test @Test
fun testTranslationEmail() { fun testTranslationEmail() {
assertTranslateContains( assertTranslateContains(
"vitor@amethyst.social", "vitor@amethyst.social",
"Have you seen this vitor@amethyst.social", "Have you seen this vitor@amethyst.social",
"pt", "pt",
) )
} }
@Test @Test
fun testTranslationLnInvoice() { fun testTranslationLnInvoice() {
assertTranslateContains( assertTranslateContains(
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
"pt", "pt",
) )
assertTranslateContains( assertTranslateContains(
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"pt", "pt",
) )
} }
@Test @Test
fun testNostrEvents() { fun testNostrEvents() {
assertTranslateContains( assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", "sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"en", "en",
) )
} }
@Test @Test
fun testJapaneseTranslationsOfUrl() { fun testJapaneseTranslationsOfUrl() {
assertTranslateContains( assertTranslateContains(
"https://youtu.be/wMYFmCDy_Eg", "https://youtu.be/wMYFmCDy_Eg",
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" + "うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
"\n" + "\n" +
"https://youtu.be/wMYFmCDy_Eg\n" + "https://youtu.be/wMYFmCDy_Eg\n" +
"\n" + "\n" +
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど", "先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
"en", "en",
) )
} }
@Test @Test
fun testEmoji() { fun testEmoji() {
assertTranslateContains( assertTranslateContains(
"https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg", "https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg",
"\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ", "\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ",
"pt", "pt",
) )
} }
} }
@@ -21,5 +21,5 @@
package com.vitorpamplona.amethyst.service.lang package com.vitorpamplona.amethyst.service.lang
object LanguageTranslatorService { object LanguageTranslatorService {
fun clear() {} fun clear() {}
} }
@@ -28,80 +28,80 @@ import com.vitorpamplona.amethyst.Amethyst
import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.UnifiedPush
interface PushDistributorActions { interface PushDistributorActions {
fun getSavedDistributor(): String fun getSavedDistributor(): String
fun getInstalledDistributors(): List<String> fun getInstalledDistributors(): List<String>
fun saveDistributor(distributor: String) fun saveDistributor(distributor: String)
fun removeSavedDistributor() fun removeSavedDistributor()
} }
object PushDistributorHandler : PushDistributorActions { object PushDistributorHandler : PushDistributorActions {
private val appContext = Amethyst.instance.applicationContext private val appContext = Amethyst.instance.applicationContext
private val unifiedPush: UnifiedPush = UnifiedPush private val unifiedPush: UnifiedPush = UnifiedPush
private var endpointInternal = "" private var endpointInternal = ""
val endpoint = endpointInternal val endpoint = endpointInternal
fun getSavedEndpoint() = endpoint fun getSavedEndpoint() = endpoint
fun setEndpoint(newEndpoint: String) { fun setEndpoint(newEndpoint: String) {
endpointInternal = newEndpoint endpointInternal = newEndpoint
Log.d("PushHandler", "New endpoint saved : $endpointInternal") Log.d("PushHandler", "New endpoint saved : $endpointInternal")
} }
fun removeEndpoint() { fun removeEndpoint() {
endpointInternal = "" endpointInternal = ""
} }
override fun getSavedDistributor(): String { override fun getSavedDistributor(): String {
return unifiedPush.getDistributor(appContext) return unifiedPush.getDistributor(appContext)
} }
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty() fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
override fun getInstalledDistributors(): List<String> { override fun getInstalledDistributors(): List<String> {
return unifiedPush.getDistributors(appContext) return unifiedPush.getDistributors(appContext)
} }
fun formattedDistributorNames(): List<String> { fun formattedDistributorNames(): List<String> {
val distributorsArray = getInstalledDistributors().toTypedArray() val distributorsArray = getInstalledDistributors().toTypedArray()
val distributorsNameArray = val distributorsNameArray =
distributorsArray distributorsArray
.map { .map {
try { try {
val ai = val ai =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.packageManager.getApplicationInfo( appContext.packageManager.getApplicationInfo(
it, it,
PackageManager.ApplicationInfoFlags.of( PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong(), PackageManager.GET_META_DATA.toLong(),
), ),
) )
} else { } else {
appContext.packageManager.getApplicationInfo(it, 0) appContext.packageManager.getApplicationInfo(it, 0)
} }
appContext.packageManager.getApplicationLabel(ai) appContext.packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
it it
} }
as String as String
} }
.toTypedArray() .toTypedArray()
return distributorsNameArray.toList() return distributorsNameArray.toList()
} }
override fun saveDistributor(distributor: String) { override fun saveDistributor(distributor: String) {
unifiedPush.saveDistributor(appContext, distributor) unifiedPush.saveDistributor(appContext, distributor)
unifiedPush.registerApp(appContext) unifiedPush.registerApp(appContext)
} }
override fun removeSavedDistributor() { override fun removeSavedDistributor() {
unifiedPush.safeRemoveDistributor(appContext) unifiedPush.safeRemoveDistributor(appContext)
} }
fun forceRemoveDistributor(context: Context) { fun forceRemoveDistributor(context: Context) {
unifiedPush.forceRemoveDistributor(context) unifiedPush.forceRemoveDistributor(context)
} }
} }
@@ -40,92 +40,92 @@ import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver import org.unifiedpush.android.connector.MessagingReceiver
class PushMessageReceiver : MessagingReceiver() { class PushMessageReceiver : MessagingReceiver() {
companion object { companion object {
private val TAG = "Amethyst-OSSPushReceiver" private val TAG = "Amethyst-OSSPushReceiver"
}
private val appContext = Amethyst.instance.applicationContext
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
override fun onMessage(
context: Context,
message: ByteArray,
instance: String,
) {
val messageStr = String(message)
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
scope.launch {
try {
parseMessage(messageStr)?.let { receiveIfNew(it) }
} catch (e: Exception) {
Log.d(TAG, "Message could not be parsed: ${e.message}")
}
} }
}
private suspend fun parseMessage(message: String): GiftWrapEvent? { private val appContext = Amethyst.instance.applicationContext
(Event.fromJson(message) as? GiftWrapEvent)?.let { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
return it private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
override fun onMessage(
context: Context,
message: ByteArray,
instance: String,
) {
val messageStr = String(message)
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
scope.launch {
try {
parseMessage(messageStr)?.let { receiveIfNew(it) }
} catch (e: Exception) {
Log.d(TAG, "Message could not be parsed: ${e.message}")
}
}
} }
return null
}
private suspend fun receiveIfNew(event: GiftWrapEvent) { private suspend fun parseMessage(message: String): GiftWrapEvent? {
if (eventCache.get(event.id) == null) { (Event.fromJson(message) as? GiftWrapEvent)?.let {
eventCache.put(event.id, event.id) return it
EventNotificationConsumer(appContext).consume(event) }
return null
} }
}
override fun onNewEndpoint( private suspend fun receiveIfNew(event: GiftWrapEvent) {
context: Context, if (eventCache.get(event.id) == null) {
endpoint: String, eventCache.put(event.id, event.id)
instance: String, EventNotificationConsumer(appContext).consume(event)
) { }
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
val sanitizedEndpoint = endpoint.dropLast(5)
pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
} }
}
override fun onReceive( override fun onNewEndpoint(
context: Context, context: Context,
intent: Intent, endpoint: String,
) { instance: String,
val intentData = intent.dataString ) {
val intentAction = intent.action.toString() Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction") val sanitizedEndpoint = endpoint.dropLast(5)
super.onReceive(context, intent) pushHandler.setEndpoint(sanitizedEndpoint)
} scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
}
}
override fun onRegistrationFailed( override fun onReceive(
context: Context, context: Context,
instance: String, intent: Intent,
) { ) {
Log.d(TAG, "Registration failed for Instance: $instance") val intentData = intent.dataString
scope.cancel() val intentAction = intent.action.toString()
pushHandler.forceRemoveDistributor(context) Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
} super.onReceive(context, intent)
}
override fun onUnregistered( override fun onRegistrationFailed(
context: Context, context: Context,
instance: String, instance: String,
) { ) {
val removedEndpoint = pushHandler.endpoint Log.d(TAG, "Registration failed for Instance: $instance")
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") scope.cancel()
Log.d(TAG, "App is unregistered. ") pushHandler.forceRemoveDistributor(context)
pushHandler.forceRemoveDistributor(context) }
pushHandler.removeEndpoint()
}
fun notificationManager(): NotificationManager { override fun onUnregistered(
return ContextCompat.getSystemService(appContext, NotificationManager::class.java) context: Context,
as NotificationManager instance: String,
} ) {
val removedEndpoint = pushHandler.endpoint
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
Log.d(TAG, "App is unregistered. ")
pushHandler.forceRemoveDistributor(context)
pushHandler.removeEndpoint()
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(appContext, NotificationManager::class.java)
as NotificationManager
}
} }
@@ -24,19 +24,19 @@ import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
object PushNotificationUtils { object PushNotificationUtils {
var hasInit: Boolean = false var hasInit: Boolean = false
private val pushHandler = PushDistributorHandler private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) { suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) { if (hasInit || pushHandler.savedDistributorExists()) {
return return
}
try {
if (pushHandler.savedDistributorExists()) {
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
}
} }
try {
if (pushHandler.savedDistributorExists()) {
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
}
}
} }
@@ -64,143 +64,142 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState = val notificationPermissionState =
CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
if (notificationPermissionState.status.isGranted) { if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) { if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
val context = LocalContext.current val context = LocalContext.current
var distributorPresent by remember { var distributorPresent by remember {
mutableStateOf(PushDistributorHandler.savedDistributorExists()) mutableStateOf(PushDistributorHandler.savedDistributorExists())
} }
if (!distributorPresent) { if (!distributorPresent) {
LoadDistributors { currentDistributor, list, readableListWithExplainer -> LoadDistributors { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) { if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog( SpinnerSelectionDialog(
title = stringResource(id = R.string.select_push_server), title = stringResource(id = R.string.select_push_server),
options = readableListWithExplainer, options = readableListWithExplainer,
onSelect = { index -> onSelect = { index ->
if (list[index] == "None") { if (list[index] == "None") {
PushDistributorHandler.forceRemoveDistributor(context) PushDistributorHandler.forceRemoveDistributor(context)
sharedPreferencesViewModel.dontAskForNotificationPermissions() sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector() sharedPreferencesViewModel.dontShowPushNotificationSelector()
} else { } else {
val fullDistributorName = list[index] val fullDistributorName = list[index]
PushDistributorHandler.saveDistributor(fullDistributorName) PushDistributorHandler.saveDistributor(fullDistributorName)
} }
distributorPresent = true distributorPresent = true
Log.d("Amethyst", "NotificationScreen: Distributor registered.") Log.d("Amethyst", "NotificationScreen: Distributor registered.")
}, },
onDismiss = { onDismiss = {
distributorPresent = true distributorPresent = true
Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.") Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.")
}, },
) )
} else { } else {
AlertDialog( AlertDialog(
onDismissRequest = { distributorPresent = true }, onDismissRequest = { distributorPresent = true },
title = { Text(stringResource(R.string.push_server_install_app)) }, title = { Text(stringResource(R.string.push_server_install_app)) },
text = { text = {
Material3RichText( Material3RichText(
style = RichTextStyle().resolveDefaults(), style = RichTextStyle().resolveDefaults(),
) { ) {
Markdown( Markdown(
content = stringResource(R.string.push_server_install_app_description), content = stringResource(R.string.push_server_install_app_description),
) )
} }
}, },
confirmButton = { confirmButton = {
Row( Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
TextButton( TextButton(
onClick = { onClick = {
distributorPresent = true distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector() sharedPreferencesViewModel.dontShowPushNotificationSelector()
}, },
) { ) {
Text(stringResource(R.string.quick_action_dont_show_again_button)) Text(stringResource(R.string.quick_action_dont_show_again_button))
} }
Button( Button(
onClick = { distributorPresent = true }, onClick = { distributorPresent = true },
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = null, contentDescription = null,
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok)) Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
},
)
} }
}
} }
}, } else {
) val currentDistributor = PushDistributorHandler.getSavedDistributor()
} PushDistributorHandler.saveDistributor(currentDistributor)
}
} }
} else {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
}
} }
}
} }
@Composable @Composable
fun LoadDistributors( fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit) {
onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
) {
val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
val list = remember { val list =
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList() remember {
} PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
}
val readableListWithExplainer = val readableListWithExplainer =
PushDistributorHandler.formattedDistributorNames() PushDistributorHandler.formattedDistributorNames()
.mapIndexed { index, name -> .mapIndexed { index, name ->
TitleExplainer( TitleExplainer(
name, name,
stringResource(id = R.string.push_server_uses_app_explainer, list[index]), stringResource(id = R.string.push_server_uses_app_explainer, list[index]),
) )
} }
.plus( .plus(
TitleExplainer( TitleExplainer(
stringResource(id = R.string.push_server_none), stringResource(id = R.string.push_server_none),
stringResource(id = R.string.push_server_none_explainer), stringResource(id = R.string.push_server_none_explainer),
), ),
) )
.toImmutableList() .toImmutableList()
onInner( onInner(
currentDistributor, currentDistributor,
list, list,
readableListWithExplainer, readableListWithExplainer,
) )
} }
@Composable @Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val context = LocalContext.current val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer -> LoadDistributors { currentDistributor, list, readableListWithExplainer ->
SettingsRow( SettingsRow(
R.string.push_server_title, R.string.push_server_title,
R.string.push_server_explainer, R.string.push_server_explainer,
selectedItens = readableListWithExplainer, selectedItens = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor), selectedIndex = list.indexOf(currentDistributor),
) { index -> ) { index ->
if (list[index] == "None") { if (list[index] == "None") {
sharedPreferencesViewModel.dontAskForNotificationPermissions() sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector() sharedPreferencesViewModel.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context) PushDistributorHandler.forceRemoveDistributor(context)
} else { } else {
PushDistributorHandler.saveDistributor(list[index]) PushDistributorHandler.saveDistributor(list[index])
} }
}
} }
}
} }
@@ -29,15 +29,14 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists
@Composable @Composable
fun TranslatableRichTextViewer( fun TranslatableRichTextViewer(
content: String, content: String,
canPreview: Boolean, canPreview: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
tags: ImmutableListOfLists<String>, tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>, backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
) = ) = ExpandableRichTextViewer(
ExpandableRichTextViewer(
content, content,
canPreview, canPreview,
modifier, modifier,
@@ -45,4 +44,4 @@ fun TranslatableRichTextViewer(
backgroundColor, backgroundColor,
accountViewModel, accountViewModel,
nav, nav,
) )
@@ -42,19 +42,18 @@ import androidx.compose.ui.unit.Velocity
* @sample androidx.compose.material.samples.PullRefreshSample * @sample androidx.compose.material.samples.PullRefreshSample
*/ */
fun Modifier.pullRefresh( fun Modifier.pullRefresh(
state: PullRefreshState, state: PullRefreshState,
enabled: Boolean = true, enabled: Boolean = true,
) = ) = inspectable(
inspectable(
inspectorInfo = inspectorInfo =
debugInspectorInfo { debugInspectorInfo {
name = "pullRefresh" name = "pullRefresh"
properties["state"] = state properties["state"] = state
properties["enabled"] = enabled properties["enabled"] = enabled
}, },
) { ) {
Modifier.pullRefresh(state::onPull, state::onRelease, enabled) Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
} }
/** /**
* A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom
@@ -78,49 +77,48 @@ fun Modifier.pullRefresh(
* @sample androidx.compose.material.samples.CustomPullRefreshSample * @sample androidx.compose.material.samples.CustomPullRefreshSample
*/ */
fun Modifier.pullRefresh( fun Modifier.pullRefresh(
onPull: (pullDelta: Float) -> Float, onPull: (pullDelta: Float) -> Float,
onRelease: suspend (flingVelocity: Float) -> Float, onRelease: suspend (flingVelocity: Float) -> Float,
enabled: Boolean = true, enabled: Boolean = true,
) = ) = inspectable(
inspectable(
inspectorInfo = inspectorInfo =
debugInspectorInfo { debugInspectorInfo {
name = "pullRefresh" name = "pullRefresh"
properties["onPull"] = onPull properties["onPull"] = onPull
properties["onRelease"] = onRelease properties["onRelease"] = onRelease
properties["enabled"] = enabled properties["enabled"] = enabled
}, },
) { ) {
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
} }
private class PullRefreshNestedScrollConnection( private class PullRefreshNestedScrollConnection(
private val onPull: (pullDelta: Float) -> Float, private val onPull: (pullDelta: Float) -> Float,
private val onRelease: suspend (flingVelocity: Float) -> Float, private val onRelease: suspend (flingVelocity: Float) -> Float,
private val enabled: Boolean, private val enabled: Boolean,
) : NestedScrollConnection { ) : NestedScrollConnection {
override fun onPreScroll( override fun onPreScroll(
available: Offset, available: Offset,
source: NestedScrollSource, source: NestedScrollSource,
): Offset = ): Offset =
when { when {
!enabled -> Offset.Zero !enabled -> Offset.Zero
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
else -> Offset.Zero else -> Offset.Zero
} }
override fun onPostScroll( override fun onPostScroll(
consumed: Offset, consumed: Offset,
available: Offset, available: Offset,
source: NestedScrollSource, source: NestedScrollSource,
): Offset = ): Offset =
when { when {
!enabled -> Offset.Zero !enabled -> Offset.Zero
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
else -> Offset.Zero else -> Offset.Zero
} }
override suspend fun onPreFling(available: Velocity): Velocity { override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y)) return Velocity(0f, onRelease(available.y))
} }
} }
@@ -70,148 +70,148 @@ import kotlin.math.pow
*/ */
@Composable @Composable
fun PullRefreshIndicator( fun PullRefreshIndicator(
refreshing: Boolean, refreshing: Boolean,
state: PullRefreshState, state: PullRefreshState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surface, backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor), contentColor: Color = contentColorFor(backgroundColor),
scale: Boolean = false, scale: Boolean = false,
) { ) {
val showElevation by val showElevation by
remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } } remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } }
Surface( Surface(
modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale), modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale),
shape = SpinnerShape, shape = SpinnerShape,
color = backgroundColor, color = backgroundColor,
shadowElevation = if (showElevation) Elevation else 0.dp, shadowElevation = if (showElevation) Elevation else 0.dp,
) { ) {
Crossfade( Crossfade(
targetState = refreshing, targetState = refreshing,
animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS), animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS),
) { refreshing -> ) { refreshing ->
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val spinnerSize = (ArcRadius + StrokeWidth).times(2) val spinnerSize = (ArcRadius + StrokeWidth).times(2)
if (refreshing) { if (refreshing) {
CircularProgressIndicator( CircularProgressIndicator(
color = contentColor, color = contentColor,
strokeWidth = StrokeWidth, strokeWidth = StrokeWidth,
modifier = Modifier.size(spinnerSize), modifier = Modifier.size(spinnerSize),
) )
} else { } else {
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
}
}
} }
}
} }
}
} }
/** Modifier.size MUST be specified. */ /** Modifier.size MUST be specified. */
@Composable @Composable
private fun CircularArrowIndicator( private fun CircularArrowIndicator(
state: PullRefreshState, state: PullRefreshState,
color: Color, color: Color,
modifier: Modifier, modifier: Modifier,
) { ) {
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
val targetAlpha by val targetAlpha by
remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } } remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } }
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
// Empty semantics for tests // Empty semantics for tests
Canvas(modifier.semantics {}) { Canvas(modifier.semantics {}) {
val values = ArrowValues(state.progress) val values = ArrowValues(state.progress)
val alpha = alphaState.value val alpha = alphaState.value
rotate(degrees = values.rotation) { rotate(degrees = values.rotation) {
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
val arcBounds = val arcBounds =
Rect( Rect(
size.center.x - arcRadius, size.center.x - arcRadius,
size.center.y - arcRadius, size.center.y - arcRadius,
size.center.x + arcRadius, size.center.x + arcRadius,
size.center.y + arcRadius, size.center.y + arcRadius,
) )
drawArc( drawArc(
color = color, color = color,
alpha = alpha, alpha = alpha,
startAngle = values.startAngle, startAngle = values.startAngle,
sweepAngle = values.endAngle - values.startAngle, sweepAngle = values.endAngle - values.startAngle,
useCenter = false, useCenter = false,
topLeft = arcBounds.topLeft, topLeft = arcBounds.topLeft,
size = arcBounds.size, size = arcBounds.size,
style = style =
Stroke( Stroke(
width = StrokeWidth.toPx(), width = StrokeWidth.toPx(),
cap = StrokeCap.Square, cap = StrokeCap.Square,
), ),
) )
drawArrow(path, arcBounds, color, alpha, values) drawArrow(path, arcBounds, color, alpha, values)
}
} }
}
} }
@Immutable @Immutable
private class ArrowValues( private class ArrowValues(
val rotation: Float, val rotation: Float,
val startAngle: Float, val startAngle: Float,
val endAngle: Float, val endAngle: Float,
val scale: Float, val scale: Float,
) )
private fun ArrowValues(progress: Float): ArrowValues { private fun ArrowValues(progress: Float): ArrowValues {
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
// How far beyond the threshold pull has gone, as a percentage of the threshold. // How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200. // Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f) val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate. // Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4 val tensionPercent = linearTension - linearTension.pow(2) / 4
// Calculations based on SwipeRefreshLayout specification. // Calculations based on SwipeRefreshLayout specification.
val endTrim = adjustedPercent * MAX_PROGRESS_ARC val endTrim = adjustedPercent * MAX_PROGRESS_ARC
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
val startAngle = rotation * 360 val startAngle = rotation * 360
val endAngle = (rotation + endTrim) * 360 val endAngle = (rotation + endTrim) * 360
val scale = min(1f, adjustedPercent) val scale = min(1f, adjustedPercent)
return ArrowValues(rotation, startAngle, endAngle, scale) return ArrowValues(rotation, startAngle, endAngle, scale)
} }
private fun DrawScope.drawArrow( private fun DrawScope.drawArrow(
arrow: Path, arrow: Path,
bounds: Rect, bounds: Rect,
color: Color, color: Color,
alpha: Float, alpha: Float,
values: ArrowValues, values: ArrowValues,
) { ) {
arrow.reset() arrow.reset()
arrow.moveTo(0f, 0f) // Move to left corner arrow.moveTo(0f, 0f) // Move to left corner
arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
// Line to tip of arrow // Line to tip of arrow
arrow.lineTo( arrow.lineTo(
x = ArrowWidth.toPx() * values.scale / 2, x = ArrowWidth.toPx() * values.scale / 2,
y = ArrowHeight.toPx() * values.scale, y = ArrowHeight.toPx() * values.scale,
) )
val radius = min(bounds.width, bounds.height) / 2f val radius = min(bounds.width, bounds.height) / 2f
val inset = ArrowWidth.toPx() * values.scale / 2f val inset = ArrowWidth.toPx() * values.scale / 2f
arrow.translate( arrow.translate(
Offset( Offset(
x = radius + bounds.center.x - inset, x = radius + bounds.center.x - inset,
y = bounds.center.y + StrokeWidth.toPx() / 2f, y = bounds.center.y + StrokeWidth.toPx() / 2f,
), ),
) )
arrow.close() arrow.close()
rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) } rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) }
} }
private const val CROSSFADE_DURATION_MS = 100 private const val CROSSFADE_DURATION_MS = 100
@@ -37,42 +37,41 @@ import androidx.compose.ui.platform.inspectable
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
*/ */
fun Modifier.pullRefreshIndicatorTransform( fun Modifier.pullRefreshIndicatorTransform(
state: PullRefreshState, state: PullRefreshState,
scale: Boolean = false, scale: Boolean = false,
) = ) = inspectable(
inspectable(
inspectorInfo = inspectorInfo =
debugInspectorInfo { debugInspectorInfo {
name = "pullRefreshIndicatorTransform" name = "pullRefreshIndicatorTransform"
properties["state"] = state properties["state"] = state
properties["scale"] = scale properties["scale"] = scale
}, },
) { ) {
Modifier Modifier
// Essentially we only want to clip the at the top, so the indicator will not appear when // Essentially we only want to clip the at the top, so the indicator will not appear when
// the position is 0. It is preferable to clip the indicator as opposed to the layout that // the position is 0. It is preferable to clip the indicator as opposed to the layout that
// contains the indicator, as this would also end up clipping shadows drawn by items in a // contains the indicator, as this would also end up clipping shadows drawn by items in a
// list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE
// for the other dimensions to allow for more room for elevation / arbitrary indicators - we // for the other dimensions to allow for more room for elevation / arbitrary indicators - we
// only ever really want to clip at the top edge. // only ever really want to clip at the top edge.
.drawWithContent { .drawWithContent {
clipRect( clipRect(
top = 0f, top = 0f,
left = -Float.MAX_VALUE, left = -Float.MAX_VALUE,
right = Float.MAX_VALUE, right = Float.MAX_VALUE,
bottom = Float.MAX_VALUE, bottom = Float.MAX_VALUE,
) { ) {
this@drawWithContent.drawContent() this@drawWithContent.drawContent()
}
} }
} .graphicsLayer {
.graphicsLayer { translationY = state.position - size.height
translationY = state.position - size.height
if (scale && !state.refreshing) { if (scale && !state.refreshing) {
val scaleFraction = val scaleFraction =
LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f) LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f)
scaleX = scaleFraction scaleX = scaleFraction
scaleY = scaleFraction scaleY = scaleFraction
}
} }
} }
}
@@ -35,10 +35,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.pow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
/** /**
* Creates a [PullRefreshState] that is remembered across compositions. * Creates a [PullRefreshState] that is remembered across compositions.
@@ -55,33 +55,33 @@ import kotlinx.coroutines.launch
*/ */
@Composable @Composable
fun rememberPullRefreshState( fun rememberPullRefreshState(
refreshing: Boolean, refreshing: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
): PullRefreshState { ): PullRefreshState {
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val onRefreshState = rememberUpdatedState(onRefresh) val onRefreshState = rememberUpdatedState(onRefresh)
val thresholdPx: Float val thresholdPx: Float
val refreshingOffsetPx: Float val refreshingOffsetPx: Float
with(LocalDensity.current) { with(LocalDensity.current) {
thresholdPx = refreshThreshold.toPx() thresholdPx = refreshThreshold.toPx()
refreshingOffsetPx = refreshingOffset.toPx() refreshingOffsetPx = refreshingOffset.toPx()
} }
val state = val state =
remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) } remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) }
SideEffect { SideEffect {
state.setRefreshing(refreshing) state.setRefreshing(refreshing)
state.setThreshold(thresholdPx) state.setThreshold(thresholdPx)
state.setRefreshingOffset(refreshingOffsetPx) state.setRefreshingOffset(refreshingOffsetPx)
} }
return state return state
} }
/** /**
@@ -98,131 +98,131 @@ fun rememberPullRefreshState(
* Should be created using [rememberPullRefreshState]. * Should be created using [rememberPullRefreshState].
*/ */
class PullRefreshState class PullRefreshState
internal constructor( internal constructor(
private val animationScope: CoroutineScope, private val animationScope: CoroutineScope,
private val onRefreshState: State<() -> Unit>, private val onRefreshState: State<() -> Unit>,
refreshingOffset: Float, refreshingOffset: Float,
threshold: Float, threshold: Float,
) { ) {
/** /**
* A float representing how far the user has pulled as a percentage of the refreshThreshold. * A float representing how far the user has pulled as a percentage of the refreshThreshold.
* *
* If the component has not been pulled at all, progress is zero. If the pull has reached halfway * If the component has not been pulled at all, progress is zero. If the pull has reached halfway
* to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond * to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond
* the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the * the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the
* refreshThreshold. * refreshThreshold.
*/ */
val progress val progress
get() = adjustedDistancePulled / threshold get() = adjustedDistancePulled / threshold
val refreshing val refreshing
get() = _refreshing get() = _refreshing
val position val position
get() = _position get() = _position
val threshold val threshold
get() = _threshold get() = _threshold
private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER } private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER }
private var _refreshing by mutableStateOf(false) private var _refreshing by mutableStateOf(false)
private var _position by mutableStateOf(0f) private var _position by mutableStateOf(0f)
private var distancePulled by mutableStateOf(0f) private var distancePulled by mutableStateOf(0f)
private var _threshold by mutableStateOf(threshold) private var _threshold by mutableStateOf(threshold)
private var refreshingOffsetState by mutableStateOf(refreshingOffset) private var refreshingOffsetState by mutableStateOf(refreshingOffset)
internal fun onPull(pullDelta: Float): Float { internal fun onPull(pullDelta: Float): Float {
if (_refreshing) return 0f // Already refreshing, do nothing. if (_refreshing) return 0f // Already refreshing, do nothing.
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled val dragConsumed = newOffset - distancePulled
distancePulled = newOffset distancePulled = newOffset
_position = calculateIndicatorPosition() _position = calculateIndicatorPosition()
return dragConsumed return dragConsumed
} }
internal fun onRelease(velocity: Float): Float { internal fun onRelease(velocity: Float): Float {
if (refreshing) return 0f // Already refreshing, do nothing if (refreshing) return 0f // Already refreshing, do nothing
if (adjustedDistancePulled > threshold) { if (adjustedDistancePulled > threshold) {
onRefreshState.value() onRefreshState.value()
}
animateIndicatorTo(0f)
val consumed =
when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
internal fun setRefreshing(refreshing: Boolean) {
if (_refreshing != refreshing) {
_refreshing = refreshing
distancePulled = 0f
animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f)
}
}
internal fun setThreshold(threshold: Float) {
_threshold = threshold
}
internal fun setRefreshingOffset(refreshingOffset: Float) {
if (refreshingOffsetState != refreshingOffset) {
refreshingOffsetState = refreshingOffset
if (refreshing) animateIndicatorTo(refreshingOffset)
}
}
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
// overhead of running through the animation pipeline instead of directly mutating the state.
private val mutatorMutex = MutatorMutex()
private fun animateIndicatorTo(offset: Float) =
animationScope.launch {
mutatorMutex.mutate {
animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value }
}
}
private fun calculateIndicatorPosition(): Float =
when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= threshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = threshold * tensionPercent
threshold + extraOffset
}
}
} }
animateIndicatorTo(0f)
val consumed =
when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
internal fun setRefreshing(refreshing: Boolean) {
if (_refreshing != refreshing) {
_refreshing = refreshing
distancePulled = 0f
animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f)
}
}
internal fun setThreshold(threshold: Float) {
_threshold = threshold
}
internal fun setRefreshingOffset(refreshingOffset: Float) {
if (refreshingOffsetState != refreshingOffset) {
refreshingOffsetState = refreshingOffset
if (refreshing) animateIndicatorTo(refreshingOffset)
}
}
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
// overhead of running through the animation pipeline instead of directly mutating the state.
private val mutatorMutex = MutatorMutex()
private fun animateIndicatorTo(offset: Float) =
animationScope.launch {
mutatorMutex.mutate {
animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value }
}
}
private fun calculateIndicatorPosition(): Float =
when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= threshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = threshold * tensionPercent
threshold + extraOffset
}
}
}
/** Default parameter values for [rememberPullRefreshState]. */ /** Default parameter values for [rememberPullRefreshState]. */
object PullRefreshDefaults { object PullRefreshDefaults {
/** /**
* If the indicator is below this threshold offset when it is released, a refresh will be * If the indicator is below this threshold offset when it is released, a refresh will be
* triggered. * triggered.
*/ */
val RefreshThreshold = 80.dp val RefreshThreshold = 80.dp
/** The offset at which the indicator should be rendered whilst a refresh is occurring. */ /** The offset at which the indicator should be rendered whilst a refresh is occurring. */
val RefreshingOffset = 56.dp val RefreshingOffset = 56.dp
} }
/** /**
@@ -29,72 +29,72 @@ import android.util.Log
import coil.ImageLoader import coil.ImageLoader
import coil.disk.DiskCache import coil.disk.DiskCache
import com.vitorpamplona.amethyst.service.playback.VideoCache import com.vitorpamplona.amethyst.service.playback.VideoCache
import java.io.File
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import kotlin.time.measureTimedValue
class Amethyst : Application() { class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
applicationIOScope.cancel() applicationIOScope.cancel()
}
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(this)
newCache
}
private val imageCache: DiskCache by lazy {
DiskCache.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
}
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder().detectAll().penaltyLog().build(),
)
} }
GlobalScope.launch(Dispatchers.IO) { val videoCache: VideoCache by lazy {
val (value, elapsed) = val newCache = VideoCache()
measureTimedValue { newCache.initFileCache(this)
// initializes the video cache in a thread newCache
videoCache }
private val imageCache: DiskCache by lazy {
DiskCache.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
}
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder().detectAll().penaltyLog().build(),
)
}
GlobalScope.launch(Dispatchers.IO) {
val (value, elapsed) =
measureTimedValue {
// initializes the video cache in a thread
videoCache
}
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
} }
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
} }
}
fun imageLoaderBuilder(): ImageLoader.Builder { fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { imageCache } return ImageLoader.Builder(applicationContext).diskCache { imageCache }
} }
companion object { companion object {
lateinit var instance: Amethyst lateinit var instance: Amethyst
private set private set
} }
} }
internal val Context.safeCacheDir: File internal val Context.safeCacheDir: File
get() { get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() } return cacheDir.apply { mkdirs() }
} }
@@ -24,28 +24,28 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
object EncryptedStorage { object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper" private const val PREFERENCES_NAME = "secret_keeper"
// returns the preferences for each account or a global file if null. // returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String { fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
} }
fun preferences(npub: String? = null): EncryptedSharedPreferences { fun preferences(npub: String? = null): EncryptedSharedPreferences {
val context = Amethyst.instance val context = Amethyst.instance
val masterKey: MasterKey = val masterKey: MasterKey =
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
val preferencesName = prefsFileName(npub) val preferencesName = prefsFileName(npub)
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, context,
preferencesName, preferencesName,
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) as EncryptedSharedPreferences ) as EncryptedSharedPreferences
} }
} }
File diff suppressed because it is too large Load Diff
@@ -62,188 +62,188 @@ import kotlinx.coroutines.launch
@Stable @Stable
class ServiceManager { class ServiceManager {
private var isStarted: Boolean = private var isStarted: Boolean =
false // to not open amber in a loop trying to use auth relays and registering for notifications false // to not open amber in a loop trying to use auth relays and registering for notifications
private var account: Account? = null private var account: Account? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var collectorJob: Job? = null private var collectorJob: Job? = null
private fun start(account: Account) { private fun start(account: Account) {
this.account = account this.account = account
start()
}
private fun start() {
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
if (isStarted && account != null) {
return
}
Log.d("ServiceManager", "Starting Relay Services")
val myAccount = account
// Resets Proxy Use
HttpClient.start(account?.proxy)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance
.imageLoaderBuilder()
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob =
scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
null
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
GlobalScope.launch(Dispatchers.IO) {
delay(3000)
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
NostrVideoDataSource.start()
}
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
isStarted = true
}
}
private fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
collectorJob?.cancel()
collectorJob = null
NostrAccountDataSource.stopSync()
NostrHomeDataSource.stopSync()
NostrChannelDataSource.stopSync()
NostrChatroomDataSource.stopSync()
NostrChatroomListDataSource.stopSync()
NostrDiscoveryDataSource.stopSync()
NostrCommunityDataSource.stopSync()
NostrHashtagDataSource.stopSync()
NostrGeohashDataSource.stopSync()
NostrSearchEventOrUserDataSource.stopSync()
NostrSingleChannelDataSource.stopSync()
NostrSingleEventDataSource.stopSync()
NostrSingleUserDataSource.stopSync()
NostrThreadDataSource.stopSync()
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
Client.reconnect(null)
isStarted = false
}
fun cleanObservers() {
LocalCache.cleanObservers()
}
fun trimMemory() {
LocalCache.cleanObservers()
val accounts =
LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
NostrChatroomDataSource.clearEOSEs(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
}
// This method keeps the pause/start in a Syncronized block to
// avoid concurrent pauses and starts.
@Synchronized
fun forceRestart(
account: Account? = null,
start: Boolean = true,
pause: Boolean = true,
) {
if (pause) {
pause()
}
if (start) {
if (account != null) {
start(account)
} else {
start() start()
}
} }
}
fun restartIfDifferentAccount(account: Account) { private fun start() {
if (this.account != account) { Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
forceRestart(account, true, true) if (isStarted && account != null) {
return
}
Log.d("ServiceManager", "Starting Relay Services")
val myAccount = account
// Resets Proxy Use
HttpClient.start(account?.proxy)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance
.imageLoaderBuilder()
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob =
scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
null
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
GlobalScope.launch(Dispatchers.IO) {
delay(3000)
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
NostrVideoDataSource.start()
}
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
isStarted = true
}
} }
}
fun forceRestart() { private fun pause() {
forceRestart(null, true, true) Log.d("ServiceManager", "Pausing Relay Services")
}
fun justStart() { collectorJob?.cancel()
forceRestart(null, true, false) collectorJob = null
}
fun pauseForGood() { NostrAccountDataSource.stopSync()
forceRestart(null, false, true) NostrHomeDataSource.stopSync()
} NostrChannelDataSource.stopSync()
NostrChatroomDataSource.stopSync()
NostrChatroomListDataSource.stopSync()
NostrDiscoveryDataSource.stopSync()
fun pauseForGoodAndClearAccount() { NostrCommunityDataSource.stopSync()
account = null NostrHashtagDataSource.stopSync()
forceRestart(null, false, true) NostrGeohashDataSource.stopSync()
} NostrSearchEventOrUserDataSource.stopSync()
NostrSingleChannelDataSource.stopSync()
NostrSingleEventDataSource.stopSync()
NostrSingleUserDataSource.stopSync()
NostrThreadDataSource.stopSync()
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
Client.reconnect(null)
isStarted = false
}
fun cleanObservers() {
LocalCache.cleanObservers()
}
fun trimMemory() {
LocalCache.cleanObservers()
val accounts =
LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
NostrChatroomDataSource.clearEOSEs(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
}
// This method keeps the pause/start in a Syncronized block to
// avoid concurrent pauses and starts.
@Synchronized
fun forceRestart(
account: Account? = null,
start: Boolean = true,
pause: Boolean = true,
) {
if (pause) {
pause()
}
if (start) {
if (account != null) {
start(account)
} else {
start()
}
}
}
fun restartIfDifferentAccount(account: Account) {
if (this.account != account) {
forceRestart(account, true, true)
}
}
fun forceRestart() {
forceRestart(null, true, true)
}
fun justStart() {
forceRestart(null, true, false)
}
fun pauseForGood() {
forceRestart(null, false, true)
}
fun pauseForGoodAndClearAccount() {
account = null
forceRestart(null, false, true)
}
} }
File diff suppressed because it is too large Load Diff
@@ -35,94 +35,94 @@ import kotlinx.coroutines.Dispatchers
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>) data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
class AntiSpamFilter { class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000) val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000) val spamMessages = LruCache<Int, Spammer>(1000)
var active: Boolean = true var active: Boolean = true
fun isSpam( fun isSpam(
event: Event, event: Event,
relay: Relay?, relay: Relay?,
): Boolean { ): Boolean {
checkNotInMainThread() checkNotInMainThread()
if (!active) return false if (!active) return false
val idHex = event.id val idHex = event.id
// if short message, ok // if short message, ok
// The idea here is to avoid considering repeated "GM" messages spam. // The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false if (event.content.length < 50) return false
// if the message is actually short but because it cites a user/event, the nostr: string is // if the message is actually short but because it cites a user/event, the nostr: string is
// really long, make it ok. // really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls // blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
// double list strategy: // double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
// Considers tags so that same replies to different people don't count. // Considers tags so that same replies to different people don't count.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ( if (
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null (recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
) {
Log.w(
"Potential SPAM Message for sharing",
"${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}",
)
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
)
// Log down offenders
logOffender(hash, event)
liveSpam.invalidateData()
return true
}
recentMessages.put(hash, idHex)
return false
}
@Synchronized
private fun logOffender(
hashCode: Int,
event: Event,
) { ) {
Log.w( if (spamMessages.get(hashCode) == null) {
"Potential SPAM Message for sharing", spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
"${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}", } else {
) val spammer = spamMessages.get(hashCode)
Log.w( spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
"Potential SPAM Message", }
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
)
// Log down offenders
logOffender(hash, event)
liveSpam.invalidateData()
return true
} }
recentMessages.put(hash, idHex) val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
return false
}
@Synchronized
private fun logOffender(
hashCode: Int,
event: Event,
) {
if (spamMessages.get(hashCode) == null) {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
} }
@Stable @Stable
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) { class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() { fun invalidateData() {
checkNotInMainThread() checkNotInMainThread()
bundler.invalidate { bundler.invalidate {
checkNotInMainThread() checkNotInMainThread()
if (hasActiveObservers()) { if (hasActiveObservers()) {
postValue(AntiSpamState(cache)) postValue(AntiSpamState(cache))
} }
}
} }
}
} }
class AntiSpamState(val cache: AntiSpamFilter) class AntiSpamState(val cache: AntiSpamFilter)
@@ -32,176 +32,176 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNote import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
@Stable @Stable
class PublicChatChannel(idHex: String) : Channel(idHex) { class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null) var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo( fun updateChannelInfo(
creator: User, creator: User,
channelInfo: ChannelCreateEvent.ChannelData, channelInfo: ChannelCreateEvent.ChannelData,
updatedAt: Long, updatedAt: Long,
) { ) {
this.info = channelInfo this.info = channelInfo
super.updateChannelInfo(creator, updatedAt) super.updateChannelInfo(creator, updatedAt)
} }
override fun toBestDisplayName(): String { override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName() return info.name ?: super.toBestDisplayName()
} }
override fun summary(): String? { override fun summary(): String? {
return info.about return info.about
} }
override fun profilePicture(): String? { override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return super.profilePicture() if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture() return info.picture ?: super.profilePicture()
} }
override fun anyNameStartsWith(prefix: String): Boolean { override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty() return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
} }
} }
@Stable @Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) { class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr() override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex() override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address fun address() = address
fun updateChannelInfo( fun updateChannelInfo(
creator: User, creator: User,
channelInfo: LiveActivitiesEvent, channelInfo: LiveActivitiesEvent,
updatedAt: Long, updatedAt: Long,
) { ) {
this.info = channelInfo this.info = channelInfo
super.updateChannelInfo(creator, updatedAt) super.updateChannelInfo(creator, updatedAt)
} }
override fun toBestDisplayName(): String { override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName() return info?.title() ?: super.toBestDisplayName()
} }
override fun summary(): String? { override fun summary(): String? {
return info?.summary() return info?.summary()
} }
override fun profilePicture(): String? { override fun profilePicture(): String? {
return info?.image()?.ifBlank { null } return info?.image()?.ifBlank { null }
} }
override fun anyNameStartsWith(prefix: String): Boolean { override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary()) return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) } .filter { it.contains(prefix, true) }
.isNotEmpty() .isNotEmpty()
} }
} }
@Stable @Stable
abstract class Channel(val idHex: String) { abstract class Channel(val idHex: String) {
var creator: User? = null var creator: User? = null
var updatedMetadataAt: Long = 0 var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>() val notes = ConcurrentHashMap<HexKey, Note>()
open fun id() = Hex.decode(idHex) open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote() open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex() open fun idDisplayNote() = idNote().toShortenHex()
open fun toBestDisplayName(): String { open fun toBestDisplayName(): String {
return idDisplayNote() return idDisplayNote()
} }
open fun summary(): String? { open fun summary(): String? {
return null return null
} }
open fun creatorName(): String? { open fun creatorName(): String? {
return creator?.toBestDisplayName() return creator?.toBestDisplayName()
} }
open fun profilePicture(): String? { open fun profilePicture(): String? {
return creator?.profilePicture() return creator?.profilePicture()
} }
open fun updateChannelInfo( open fun updateChannelInfo(
creator: User, creator: User,
updatedAt: Long, updatedAt: Long,
) { ) {
this.creator = creator this.creator = creator
this.updatedMetadataAt = updatedAt this.updatedMetadataAt = updatedAt
live.invalidateData() live.invalidateData()
} }
fun addNote(note: Note) { fun addNote(note: Note) {
notes[note.idHex] = note notes[note.idHex] = note
} }
fun removeNote(note: Note) { fun removeNote(note: Note) {
notes.remove(note.idHex) notes.remove(note.idHex)
} }
fun removeNote(noteHex: String) { fun removeNote(noteHex: String) {
notes.remove(noteHex) notes.remove(noteHex)
} }
abstract fun anyNameStartsWith(prefix: String): Boolean abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here. // Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this) val live: ChannelLiveData = ChannelLiveData(this)
fun pruneOldAndHiddenMessages(account: Account): Set<Note> { fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = val important =
notes.values notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false } .filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed() .reversed()
.take(1000) .take(1000)
.toSet() .toSet()
val toBeRemoved = notes.values.filter { it !in important }.toSet() val toBeRemoved = notes.values.filter { it !in important }.toSet()
toBeRemoved.forEach { notes.remove(it.idHex) } toBeRemoved.forEach { notes.remove(it.idHex) }
return toBeRemoved return toBeRemoved
} }
} }
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) { class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() { fun invalidateData() {
checkNotInMainThread() checkNotInMainThread()
bundler.invalidate { bundler.invalidate {
checkNotInMainThread() checkNotInMainThread()
if (hasActiveObservers()) { if (hasActiveObservers()) {
postValue(ChannelState(channel)) postValue(ChannelState(channel))
} }
}
} }
}
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
NostrSingleChannelDataSource.add(channel) NostrSingleChannelDataSource.add(channel)
} }
override fun onInactive() { override fun onInactive() {
super.onInactive() super.onInactive()
NostrSingleChannelDataSource.remove(channel) NostrSingleChannelDataSource.remove(channel)
} }
} }
class ChannelState(val channel: Channel) class ChannelState(val channel: Channel)
@@ -27,62 +27,62 @@ import com.vitorpamplona.quartz.utils.TimeUtils
@Stable @Stable
class Chatroom() { class Chatroom() {
var roomMessages: Set<Note> = setOf() var roomMessages: Set<Note> = setOf()
var subject: String? = null var subject: String? = null
var subjectCreatedAt: Long? = null var subjectCreatedAt: Long? = null
@Synchronized @Synchronized
fun addMessageSync(msg: Note) { fun addMessageSync(msg: Note) {
checkNotInMainThread() checkNotInMainThread()
if (msg !in roomMessages) { if (msg !in roomMessages) {
roomMessages = roomMessages + msg roomMessages = roomMessages + msg
val newSubject = msg.event?.subject() val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject subject = newSubject
subjectCreatedAt = msg.createdAt() subjectCreatedAt = msg.createdAt()
} }
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages
.filter { it.event?.subject() != null }
.sortedBy { it.createdAt() }
.lastOrNull()
?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
} }
} }
}
fun senderIntersects(keySet: Set<HexKey>): Boolean { @Synchronized
return roomMessages.any { it.author?.pubkeyHex in keySet } fun removeMessageSync(msg: Note) {
} checkNotInMainThread()
fun pruneMessagesToTheLatestOnly(): Set<Note> { if (msg !in roomMessages) {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() roomMessages = roomMessages + msg
val toKeep = roomMessages
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { .filter { it.event?.subject() != null }
// Recent messages, keep last 100 .sortedBy { it.createdAt() }
sorted.take(100).toSet() .lastOrNull()
} else { ?.let {
// Old messages, keep the last one. subject = it.event?.subject()
sorted.take(1).toSet() subjectCreatedAt = it.createdAt()
} + sorted.filter { it.liveSet?.isInUse() ?: false } }
}
}
val toRemove = roomMessages.minus(toKeep) fun senderIntersects(keySet: Set<HexKey>): Boolean {
roomMessages = toKeep return roomMessages.any { it.author?.pubkeyHex in keySet }
return toRemove }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep =
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
} }
@@ -28,144 +28,154 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon( fun checkForHashtagWithIcon(
tag: String, tag: String,
primary: Color, primary: Color,
): HashtagIcon? { ): HashtagIcon? {
return when (tag.lowercase()) { return when (tag.lowercase()) {
"bitcoin", "bitcoin",
"btc", "btc",
"timechain", "timechain",
"bitcoiner", "bitcoiner",
"bitcoiners", -> "bitcoiners",
HashtagIcon( ->
R.drawable.ht_btc, HashtagIcon(
"Bitcoin", R.drawable.ht_btc,
Color.Unspecified, "Bitcoin",
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), Color.Unspecified,
) Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
"nostr", )
"nostrich", "nostr",
"nostriches", "nostrich",
"thenostr", -> "nostriches",
HashtagIcon( "thenostr",
R.drawable.ht_nostr, ->
"Nostr", HashtagIcon(
Color.Unspecified, R.drawable.ht_nostr,
Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp), "Nostr",
) Color.Unspecified,
"lightning", Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp),
"lightningnetwork", -> )
HashtagIcon( "lightning",
R.drawable.ht_lightning, "lightningnetwork",
"Lightning", ->
Color.Unspecified, HashtagIcon(
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), R.drawable.ht_lightning,
) "Lightning",
"zap", Color.Unspecified,
"zaps", Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
"zapper", )
"zappers", "zap",
"zapping", "zaps",
"zapped", "zapper",
"zapathon", "zappers",
"zapraiser", "zapping",
"zaplife", "zapped",
"zapchain", -> "zapathon",
HashtagIcon( "zapraiser",
R.drawable.zap, "zaplife",
"Zap", "zapchain",
Color.Unspecified, ->
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), HashtagIcon(
) R.drawable.zap,
"amethyst" -> "Zap",
HashtagIcon( Color.Unspecified,
R.drawable.amethyst, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
"Amethyst", )
Color.Unspecified, "amethyst" ->
Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp), HashtagIcon(
) R.drawable.amethyst,
"onyx" -> "Amethyst",
HashtagIcon( Color.Unspecified,
R.drawable.black_heart, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp),
"Onyx", )
Color.Unspecified, "onyx" ->
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), HashtagIcon(
) R.drawable.black_heart,
"cashu", "Onyx",
"ecash", Color.Unspecified,
"nut", Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
"nuts", )
"deeznuts", -> "cashu",
HashtagIcon( "ecash",
R.drawable.cashu, "nut",
"Cashu", "nuts",
Color.Unspecified, "deeznuts",
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), ->
) HashtagIcon(
"plebs", R.drawable.cashu,
"pleb", "Cashu",
"plebchain", -> Color.Unspecified,
HashtagIcon( Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
R.drawable.plebs, )
"Pleb", "plebs",
Color.Unspecified, "pleb",
Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp), "plebchain",
) ->
"coffee", HashtagIcon(
"coffeechain", R.drawable.plebs,
"cafe", -> "Pleb",
HashtagIcon( Color.Unspecified,
R.drawable.coffee, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp),
"Coffee", )
Color.Unspecified, "coffee",
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), "coffeechain",
) "cafe",
"skullofsatoshi" -> ->
HashtagIcon( HashtagIcon(
R.drawable.skull, R.drawable.coffee,
"SkullofSatoshi", "Coffee",
Color.Unspecified, Color.Unspecified,
Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp), Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
) )
"grownostr", "skullofsatoshi" ->
"gardening", HashtagIcon(
"garden", -> R.drawable.skull,
HashtagIcon( "SkullofSatoshi",
R.drawable.grownostr, Color.Unspecified,
"GrowNostr", Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp),
Color.Unspecified, )
Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp), "grownostr",
) "gardening",
"footstr" -> "garden",
HashtagIcon( ->
R.drawable.footstr, HashtagIcon(
"Footstr", R.drawable.grownostr,
Color.Unspecified, "GrowNostr",
Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp), Color.Unspecified,
) Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp),
"tunestr", )
"music", "footstr" ->
"nowplaying", -> HashtagIcon(
HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp)) R.drawable.footstr,
"weed", "Footstr",
"weedstr", Color.Unspecified,
"420", Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp),
"cannabis", )
"marijuana", -> "tunestr",
HashtagIcon( "music",
R.drawable.weed, "nowplaying",
"Weed", ->
Color.Unspecified, HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), "weed",
) "weedstr",
else -> null "420",
} "cannabis",
"marijuana",
->
HashtagIcon(
R.drawable.weed,
"Weed",
Color.Unspecified,
Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp),
)
else -> null
}
} }
@Immutable @Immutable
class HashtagIcon( class HashtagIcon(
val icon: Int, val icon: Int,
val description: String, val description: String,
val color: Color, val color: Color,
val modifier: Modifier, val modifier: Modifier,
) )
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -23,94 +23,94 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
class ParticipantListBuilder { class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet( private fun addFollowsThatDirectlyParticipateOnToSet(
baseNote: Note, baseNote: Note,
followingSet: Set<HexKey>?, followingSet: Set<HexKey>?,
set: MutableSet<User>, set: MutableSet<User>,
) { ) {
baseNote.author?.let { author -> baseNote.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author) set.add(author)
} }
}
// Breaks these searchers down to avoid the memory use of creating multiple lists
baseNote.replies.forEach { reply ->
reply.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
} }
}
}
baseNote.boosts.forEach { boost -> // Breaks these searchers down to avoid the memory use of creating multiple lists
boost.author?.let { author -> baseNote.replies.forEach { reply ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { reply.author?.let { author ->
set.add(author) if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author)
}
}
} }
}
}
baseNote.zaps.forEach { zapPair -> baseNote.boosts.forEach { boost ->
zapPair.key.author?.let { author -> boost.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
set.add(author) set.add(author)
}
}
} }
}
}
baseNote.reactions.forEach { reactionSet -> baseNote.zaps.forEach { zapPair ->
reactionSet.value.forEach { reaction -> zapPair.key.author?.let { author ->
reaction.author?.let { author -> if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { set.add(author)
set.add(author) }
} }
} }
}
}
}
fun followsThatParticipateOnDirect( baseNote.reactions.forEach { reactionSet ->
baseNote: Note?, reactionSet.value.forEach { reaction ->
followingSet: Set<HexKey>?, reaction.author?.let { author ->
): Set<User> { if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
if (baseNote == null) return mutableSetOf() set.add(author)
}
val set = mutableSetOf<User>() }
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set) }
return set }
}
fun followsThatParticipateOn(
baseNote: Note?,
followingSet: Set<HexKey>?,
): Set<User> {
if (baseNote == null) return mutableSetOf()
val mySet = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
baseNote.boosts.forEach {
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
} }
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach { fun followsThatParticipateOnDirect(
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) baseNote: Note?,
followingSet: Set<HexKey>?,
): Set<User> {
if (baseNote == null) return mutableSetOf()
val set = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
return set
} }
return mySet fun followsThatParticipateOn(
} baseNote: Note?,
followingSet: Set<HexKey>?,
): Set<User> {
if (baseNote == null) return mutableSetOf()
fun countFollowsThatParticipateOn( val mySet = mutableSetOf<User>()
baseNote: Note?, addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
followingSet: Set<HexKey>?,
): Int {
if (baseNote == null) return 0
val list = followsThatParticipateOn(baseNote, followingSet) baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
return list.size baseNote.boosts.forEach {
} it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
return mySet
}
fun countFollowsThatParticipateOn(
baseNote: Note?,
followingSet: Set<HexKey>?,
): Int {
if (baseNote == null) return 0
val list = followsThatParticipateOn(baseNote, followingSet)
return list.size
}
} }
@@ -26,57 +26,56 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@Stable @Stable
class RelayInformation( class RelayInformation(
val id: String?, val id: String?,
val name: String?, val name: String?,
val description: String?, val description: String?,
val pubkey: String?, val pubkey: String?,
val contact: String?, val contact: String?,
val supported_nips: List<Int>?, val supported_nips: List<Int>?,
val supported_nip_extensions: List<String>?, val supported_nip_extensions: List<String>?,
val software: String?, val software: String?,
val version: String?, val version: String?,
val limitation: RelayInformationLimitation?, val limitation: RelayInformationLimitation?,
val relay_countries: List<String>?, val relay_countries: List<String>?,
val language_tags: List<String>?, val language_tags: List<String>?,
val tags: List<String>?, val tags: List<String>?,
val posting_policy: String?, val posting_policy: String?,
val payments_url: String?, val payments_url: String?,
val fees: RelayInformationFees?, val fees: RelayInformationFees?,
) { ) {
companion object { companion object {
val mapper = val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun fromJson(json: String): RelayInformation = fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java)
mapper.readValue(json, RelayInformation::class.java) }
}
} }
@Stable @Stable
class RelayInformationFee( class RelayInformationFee(
val amount: Int?, val amount: Int?,
val unit: String?, val unit: String?,
val period: Int?, val period: Int?,
val kinds: List<Int>?, val kinds: List<Int>?,
) )
class RelayInformationFees( class RelayInformationFees(
val admission: List<RelayInformationFee>?, val admission: List<RelayInformationFee>?,
val subscription: List<RelayInformationFee>?, val subscription: List<RelayInformationFee>?,
val publication: List<RelayInformationFee>?, val publication: List<RelayInformationFee>?,
val retention: List<RelayInformationFee>?, val retention: List<RelayInformationFee>?,
) )
class RelayInformationLimitation( class RelayInformationLimitation(
val max_message_length: Int?, val max_message_length: Int?,
val max_subscriptions: Int?, val max_subscriptions: Int?,
val max_filters: Int?, val max_filters: Int?,
val max_limit: Int?, val max_limit: Int?,
val max_subid_length: Int?, val max_subid_length: Int?,
val min_prefix: Int?, val min_prefix: Int?,
val max_event_tags: Int?, val max_event_tags: Int?,
val max_content_length: Int?, val max_content_length: Int?,
val min_pow_difficulty: Int?, val min_pow_difficulty: Int?,
val auth_required: Boolean?, val auth_required: Boolean?,
val payment_required: Boolean?, val payment_required: Boolean?,
) )
@@ -25,15 +25,15 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
@Immutable @Immutable
data class RelaySetupInfo( data class RelaySetupInfo(
val url: String, val url: String,
val read: Boolean, val read: Boolean,
val write: Boolean, val write: Boolean,
val errorCount: Int = 0, val errorCount: Int = 0,
val downloadCountInBytes: Int = 0, val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0, val uploadCountInBytes: Int = 0,
val spamCount: Int = 0, val spamCount: Int = 0,
val feedTypes: Set<FeedType>, val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false, val paidRelay: Boolean = false,
) { ) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
} }
@@ -25,83 +25,83 @@ import com.vitorpamplona.amethyst.R
@Stable @Stable
data class Settings( data class Settings(
val theme: ThemeType = ThemeType.SYSTEM, val theme: ThemeType = ThemeType.SYSTEM,
val preferredLanguage: String? = null, val preferredLanguage: String? = null,
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS, val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS, val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS, val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS, val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false, val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false, val dontAskForNotificationPermissions: Boolean = false,
) )
enum class ThemeType(val screenCode: Int, val resourceId: Int) { enum class ThemeType(val screenCode: Int, val resourceId: Int) {
SYSTEM(0, R.string.system), SYSTEM(0, R.string.system),
LIGHT(1, R.string.light), LIGHT(1, R.string.light),
DARK(2, R.string.dark), DARK(2, R.string.dark),
} }
fun parseThemeType(code: Int?): ThemeType { fun parseThemeType(code: Int?): ThemeType {
return when (code) { return when (code) {
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
ThemeType.DARK.screenCode -> ThemeType.DARK ThemeType.DARK.screenCode -> ThemeType.DARK
else -> { else -> {
ThemeType.SYSTEM ThemeType.SYSTEM
}
} }
}
} }
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) { enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always), ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only), WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never), NEVER(false, 2, R.string.connectivity_type_never),
} }
fun parseConnectivityType(code: Boolean?): ConnectivityType { fun parseConnectivityType(code: Boolean?): ConnectivityType {
return when (code) { return when (code) {
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
else -> { else -> {
ConnectivityType.ALWAYS ConnectivityType.ALWAYS
}
} }
}
} }
fun parseConnectivityType(screenCode: Int): ConnectivityType { fun parseConnectivityType(screenCode: Int): ConnectivityType {
return when (screenCode) { return when (screenCode) {
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
else -> { else -> {
ConnectivityType.ALWAYS ConnectivityType.ALWAYS
}
} }
}
} }
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) { enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always), ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never), NEVER(false, 1, R.string.connectivity_type_never),
} }
fun parseBooleanType(code: Boolean?): BooleanType { fun parseBooleanType(code: Boolean?): BooleanType {
return when (code) { return when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> { else -> {
BooleanType.ALWAYS BooleanType.ALWAYS
}
} }
}
} }
fun parseBooleanType(screenCode: Int): BooleanType { fun parseBooleanType(screenCode: Int): BooleanType {
return when (screenCode) { return when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> { else -> {
BooleanType.ALWAYS BooleanType.ALWAYS
}
} }
}
} }
@@ -26,86 +26,86 @@ import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
class ThreadAssembler { class ThreadAssembler {
private fun searchRoot( private fun searchRoot(
note: Note, note: Note,
testedNotes: MutableSet<Note> = mutableSetOf(), testedNotes: MutableSet<Note> = mutableSetOf(),
): Note? { ): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
testedNotes.add(note) testedNotes.add(note)
val markedAsRoot = val markedAsRoot =
note.event note.event
?.tags() ?.tags()
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1) ?.getOrNull(1)
if (markedAsRoot != null) { if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies // Check to ssee if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
return LocalCache.checkGetOrCreateNote(markedAsRoot) return LocalCache.checkGetOrCreateNote(markedAsRoot)
} }
}
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
val roots =
note.replyTo
?.map {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
} }
?.filterNotNull()
if (roots != null && roots.isNotEmpty()) { val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
return roots[0] if (hasNoReplyTo != null) return hasNoReplyTo
}
return null // recursive
} val roots =
note.replyTo
?.map {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}
?.filterNotNull()
fun findThreadFor(noteId: String): Set<Note> { if (roots != null && roots.isNotEmpty()) {
checkNotInMainThread() return roots[0]
val (result, elapsed) =
measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
thread.toSet()
} else {
setOf(note)
} }
}
println("Model Refresh: Thread loaded in $elapsed") return null
}
return result
} fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()
fun loadDown(
note: Note, val (result, elapsed) =
thread: MutableSet<Note>, measureTimedValue {
) { val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note !in thread) {
thread.add(note) if (note.event != null) {
val thread = mutableSetOf<Note>()
note.replies.forEach { loadDown(it, thread) }
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
thread.toSet()
} else {
setOf(note)
}
}
println("Model Refresh: Thread loaded in $elapsed")
return result
}
fun loadDown(
note: Note,
thread: MutableSet<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replies.forEach { loadDown(it, thread) }
}
} }
}
} }
@@ -31,55 +31,54 @@ import kotlinx.coroutines.withContext
@Stable @Stable
object UrlCachedPreviewer { object UrlCachedPreviewer {
var cache = LruCache<String, UrlPreviewState>(100) var cache = LruCache<String, UrlPreviewState>(100)
private set private set
suspend fun previewInfo( suspend fun previewInfo(
url: String, url: String,
onReady: suspend (UrlPreviewState) -> Unit, onReady: suspend (UrlPreviewState) -> Unit,
) = ) = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { cache[url]?.let {
cache[url]?.let { onReady(it)
onReady(it) return@withContext
return@withContext }
}
BahaUrlPreview( BahaUrlPreview(
url, url,
object : IUrlPreviewCallback { object : IUrlPreviewCallback {
override suspend fun onComplete(urlInfo: UrlInfoItem) = override suspend fun onComplete(urlInfo: UrlInfoItem) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
cache[url]?.let { cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it) onReady(it)
return@withContext return@withContext
} }
} }
val state = val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) { if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo) UrlPreviewState.Loaded(urlInfo)
} else { } else {
UrlPreviewState.Empty UrlPreviewState.Empty
} }
cache.put(url, state) cache.put(url, state)
onReady(state) onReady(state)
} }
override suspend fun onFailed(throwable: Throwable) = override suspend fun onFailed(throwable: Throwable) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
cache[url]?.let { cache[url]?.let {
onReady(it) onReady(it)
return@withContext return@withContext
} }
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview") val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state) cache.put(url, state)
onReady(state) onReady(state)
} }
}, },
) )
.fetchUrlPreview() .fetchUrlPreview()
} }
} }
File diff suppressed because it is too large Load Diff
@@ -27,296 +27,294 @@ import kotlin.math.pow
import kotlin.math.withSign import kotlin.math.withSign
object BlurHashDecoder { object BlurHashDecoder {
// cache Math.cos() calculations to improve performance. // cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY *
// 2 * nBitmaps // 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images // the cache is enabled by default, it is recommended to disable it only when just a few images
// are displayed // are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>() private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>() private val cacheCosinesY = HashMap<Int, DoubleArray>()
/** /**
* Clear calculations stored in memory cache. The cache is not big, but will increase when many * Clear calculations stored in memory cache. The cache is not big, but will increase when many
* image sizes are used, if the app needs memory it is recommended to clear it. * image sizes are used, if the app needs memory it is recommended to clear it.
*/ */
fun clearCache() { fun clearCache() {
cacheCosinesX.clear() cacheCosinesX.clear()
cacheCosinesY.clear() cacheCosinesY.clear()
}
/** Returns width/height */
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
} }
return numCompX.toFloat() / numCompY.toFloat() /** Returns width/height */
} fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
/** return numCompX.toFloat() / numCompY.toFloat()
* Decode a blur hash into a new bitmap. }
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations. By
* default it is true.
*/
fun decode(
blurHash: String?,
width: Int,
height: Int,
punch: Float = 1f,
useCache: Boolean = true,
): Bitmap? {
checkNotInMainThread()
if (blurHash == null || blurHash.length < 6) { /**
return null * Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations. By
* default it is true.
*/
fun decode(
blurHash: String?,
width: Int,
height: Int,
punch: Float = 1f,
useCache: Boolean = true,
): Bitmap? {
checkNotInMainThread()
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors =
Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
} }
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1 private fun decode83(
val numCompY = (numCompEnc / 9) + 1 str: String,
if (blurHash.length != 4 + 2 * numCompX * numCompY) { from: Int = 0,
return null to: Int = str.length,
): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
} }
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f private fun decodeDc(colorEnc: Int): FloatArray {
val colors = val r = colorEnc shr 16
Array(numCompX * numCompY) { i -> val g = (colorEnc shr 8) and 255
if (i == 0) { val b = colorEnc and 255
val colorEnc = decode83(blurHash, 2, 6) return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
decodeDc(colorEnc) }
private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else { } else {
val from = 4 + i * 2 ((v + 0.055f) / 1.055f).pow(2.4f)
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
} }
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(
str: String,
from: Int = 0,
to: Int = str.length,
): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
} }
return result
}
private fun decodeDc(colorEnc: Int): FloatArray { private fun decodeAc(
val r = colorEnc shr 16 value: Int,
val g = (colorEnc shr 8) and 255 maxAc: Float,
val b = colorEnc and 255 ): FloatArray {
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) val r = value / (19 * 19)
} val g = (value / 19) % 19
val b = value % 19
private fun srgbToLinear(colorEnc: Int): Float { return floatArrayOf(
val v = colorEnc / 255f signedPow2((r - 9) / 9.0f) * maxAc,
return if (v <= 0.04045f) { signedPow2((g - 9) / 9.0f) * maxAc,
(v / 12.92f) signedPow2((b - 9) / 9.0f) * maxAc,
} else { )
((v + 0.055f) / 1.055f).pow(2.4f)
} }
}
private fun decodeAc( private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
value: Int,
maxAc: Float,
): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc,
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value) private fun composeBitmap(
width: Int,
private fun composeBitmap( height: Int,
width: Int, numCompX: Int,
height: Int, numCompY: Int,
numCompX: Int, colors: Array<FloatArray>,
numCompY: Int, useCache: Boolean,
colors: Array<FloatArray>, ): Bitmap {
useCache: Boolean, // use an array for better performance when writing pixel colors
): Bitmap { val imageArray = IntArray(width * height)
// use an array for better performance when writing pixel colors val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val imageArray = IntArray(width * height) val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) for (y in 0 until height) {
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) for (x in 0 until width) {
for (y in 0 until height) { var r = 0f
for (x in 0 until width) { var g = 0f
var r = 0f var b = 0f
var g = 0f for (j in 0 until numCompY) {
var b = 0f for (i in 0 until numCompX) {
for (j in 0 until numCompY) { val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
for (i in 0 until numCompX) { val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) val basis = (cosX * cosY).toFloat()
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) val color = colors[j * numCompX + i]
val basis = (cosX * cosY).toFloat() r += color[0] * basis
val color = colors[j * numCompX + i] g += color[1] * basis
r += color[0] * basis b += color[2] * basis
g += color[1] * basis }
b += color[2] * basis }
} imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
} }
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(
calculate: Boolean,
height: Int,
numCompY: Int,
) =
when {
calculate -> {
DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it }
}
else -> {
cacheCosinesY[height * numCompY]!!
}
} }
private fun getArrayForCosinesX( private fun getArrayForCosinesY(
calculate: Boolean, calculate: Boolean,
width: Int, height: Int,
numCompX: Int, numCompY: Int,
) = ) = when {
when { calculate -> {
calculate -> { DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it }
DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it } }
} else -> {
else -> cacheCosinesX[width * numCompX]!! cacheCosinesY[height * numCompY]!!
}
} }
private fun DoubleArray.getCos( private fun getArrayForCosinesX(
calculate: Boolean, calculate: Boolean,
x: Int, width: Int,
numComp: Int, numCompX: Int,
y: Int, ) = when {
size: Int, calculate -> {
): Double { DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it }
if (calculate) { }
this[x + numComp * y] = cos(Math.PI * y * x / size) else -> cacheCosinesX[width * numCompX]!!
} }
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int { private fun DoubleArray.getCos(
val v = value.coerceIn(0f, 1f) calculate: Boolean,
return if (v <= 0.0031308f) { x: Int,
(v * 12.92f * 255f + 0.5f).toInt() numComp: Int,
} else { y: Int,
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() size: Int,
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
} }
}
private val charMap = private fun linearToSrgb(value: Float): Int {
listOf( val v = value.coerceIn(0f, 1f)
'0', return if (v <= 0.0031308f) {
'1', (v * 12.92f * 255f + 0.5f).toInt()
'2', } else {
'3', ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
'4', }
'5', }
'6',
'7', private val charMap =
'8', listOf(
'9', '0',
'A', '1',
'B', '2',
'C', '3',
'D', '4',
'E', '5',
'F', '6',
'G', '7',
'H', '8',
'I', '9',
'J', 'A',
'K', 'B',
'L', 'C',
'M', 'D',
'N', 'E',
'O', 'F',
'P', 'G',
'Q', 'H',
'R', 'I',
'S', 'J',
'T', 'K',
'U', 'L',
'V', 'M',
'W', 'N',
'X', 'O',
'Y', 'P',
'Z', 'Q',
'a', 'R',
'b', 'S',
'c', 'T',
'd', 'U',
'e', 'V',
'f', 'W',
'g', 'X',
'h', 'Y',
'i', 'Z',
'j', 'a',
'k', 'b',
'l', 'c',
'm', 'd',
'n', 'e',
'o', 'f',
'p', 'g',
'q', 'h',
'r', 'i',
's', 'j',
't', 'k',
'u', 'l',
'v', 'm',
'w', 'n',
'x', 'o',
'y', 'p',
'z', 'q',
'#', 'r',
'$', 's',
'%', 't',
'*', 'u',
'+', 'v',
',', 'w',
'-', 'x',
'.', 'y',
':', 'z',
';', '#',
'=', '$',
'?', '%',
'@', '*',
'[', '+',
']', ',',
'^', '-',
'_', '.',
'{', ':',
'|', ';',
'}', '=',
'~', '?',
) '@',
.mapIndexed { i, c -> c to i } '[',
.toMap() ']',
'^',
'_',
'{',
'|',
'}',
'~',
)
.mapIndexed { i, c -> c to i }
.toMap()
} }
@@ -37,58 +37,58 @@ import kotlin.math.roundToInt
@Stable @Stable
class BlurHashFetcher( class BlurHashFetcher(
private val options: Options, private val options: Options,
private val data: Uri, private val data: Uri,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
checkNotInMainThread() checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:") val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8") val hash = URLDecoder.decode(encodedHash, "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100 val preferredWidth = 100
val bitmap = val bitmap =
BlurHashDecoder.decode( BlurHashDecoder.decode(
hash, hash,
preferredWidth, preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt(), (preferredWidth * (1 / aspectRatio)).roundToInt(),
) )
if (bitmap == null) { if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash") throw Exception("Unable to convert Bluehash $hash")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} }
return DrawableResult( object Factory : Fetcher.Factory<Uri> {
drawable = bitmap.toDrawable(options.context.resources), override fun create(
isSampled = false, data: Uri,
dataSource = DataSource.MEMORY, options: Options,
) imageLoader: ImageLoader,
} ): Fetcher {
return BlurHashFetcher(options, data)
object Factory : Fetcher.Factory<Uri> { }
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BlurHashFetcher(options, data)
} }
}
} }
object BlurHashRequester { object BlurHashRequester {
fun imageRequest( fun imageRequest(
context: Context, context: Context,
message: String, message: String,
): ImageRequest { ): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8") val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data("bluehash:$encodedMessage") .data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory) .fetcherFactory(BlurHashFetcher.Factory)
.build() .build()
} }
} }
@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionCom
import com.vitorpamplona.amethyst.ui.components.tagIndex import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.ImmutableListOfLists
import java.util.regex.Pattern
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
@@ -43,33 +42,34 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import java.util.regex.Pattern
@Immutable @Immutable
data class RichTextViewerState( data class RichTextViewerState(
val urlSet: ImmutableSet<String>, val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>, val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>, val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>, val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>, val paragraphs: ImmutableList<ParagraphState>,
) )
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean) data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser { object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200) val richTextCache = LruCache<String, RichTextViewerState>(200)
fun parseText( fun parseText(
content: String, content: String,
tags: ImmutableListOfLists<String>, tags: ImmutableListOfLists<String>,
): RichTextViewerState { ): RichTextViewerState {
return if (richTextCache[content] != null) { return if (richTextCache[content] != null) {
richTextCache[content] richTextCache[content]
} else { } else {
val newUrls = RichTextParser().parseText(content, tags) val newUrls = RichTextParser().parseText(content, tags)
richTextCache.put(content, newUrls) richTextCache.put(content, newUrls)
newUrls newUrls
}
} }
}
} }
// Group 1 = url, group 4 additional chars // Group 1 = url, group 4 additional chars
@@ -78,236 +78,236 @@ object CachedRichTextParser {
// Android9 seems to have an issue starting this regex. // Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = val noProtocolUrlValidator =
try { try {
Pattern.compile( Pattern.compile(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)",
) )
} catch (e: Exception) { } catch (e: Exception) {
Pattern.compile( Pattern.compile(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)",
) )
} }
val HTTPRegex = val HTTPRegex =
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE) .toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() { class RichTextParser() {
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? { fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl) val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlImage( ZoomableUrlImage(
url = fullUrl, url = fullUrl,
description = frags["alt"], description = frags["alt"],
hash = frags["x"], hash = frags["x"],
blurhash = frags["blurhash"], blurhash = frags["blurhash"],
dim = frags["dim"], dim = frags["dim"],
) )
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl) val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlVideo( ZoomableUrlVideo(
url = fullUrl, url = fullUrl,
description = frags["alt"], description = frags["alt"],
hash = frags["x"], hash = frags["x"],
blurhash = frags["blurhash"], blurhash = frags["blurhash"],
dim = frags["dim"], dim = frags["dim"],
) )
} else {
null
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet =
urls.mapNotNullTo(LinkedHashSet(urls.size)) {
// removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else { } else {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
null null
}
} }
}
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
segments,
)
}
private fun findTextSegments(
content: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
}
val newSegments =
if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
} }
return paragraphSegments fun parseText(
} content: String,
tags: ImmutableListOfLists<String>,
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
fun isNumber(word: String): Boolean { val urlSet =
return numberPattern.matcher(word).matches() urls.mapNotNullTo(LinkedHashSet(urls.size)) {
} // removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
fun isDate(word: String): Boolean { null
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() } else if (isNumber(it.originalUrl)) {
} null
} else if (it.originalUrl.contains("")) {
private fun isArabic(text: String): Boolean { null
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } } else {
} if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
private fun wordIdentifier( } else {
word: String, null
images: Set<String>, }
urls: Set<String>, }
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): Segment {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
return if (word.isEmpty()) {
RegularTextSegment(word)
} else if (images.contains(word)) {
ImageSegment(word)
} else if (urls.contains(word)) {
LinkSegment(word)
} else if (emojis.any { word.contains(it.key) }) {
EmojiSegment(word)
} else if (word.startsWith("lnbc", true)) {
InvoiceSegment(word)
} else if (word.startsWith("lnurl", true)) {
WithdrawSegment(word)
} else if (word.startsWith("cashuA", true)) {
CashuSegment(word)
} else if (emailMatcher.matches()) {
EmailSegment(word)
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
PhoneSegment(word)
} else if (startsWithNIP19Scheme(word)) {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
}
} else {
RegularTextSegment(word)
}
}
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
try {
if (matcher.find()) {
val index = matcher.group(1)?.toInt()
val suffix = matcher.group(2)
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
} }
}
} val imagesForPager =
} urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url }
} catch (e: Exception) { val imageList = imagesForPager.values.toList()
Log.w("Tag Parser", "Couldn't link tag $word", e)
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
segments,
)
} }
// Second #Amethyst private fun findTextSegments(
val hashtagMatcher = hashTagsPattern.matcher(word) content: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
try { content.split('\n').forEach { paragraph ->
if (hashtagMatcher.find()) { var segments = persistentListOf<Segment>()
val hashtag = hashtagMatcher.group(1) var isDirty = false
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2)) val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
}
val newSegments =
if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
} }
}
} catch (e: Exception) { return paragraphSegments
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
} }
return RegularTextSegment(word) fun isNumber(word: String): Boolean {
} return numberPattern.matcher(word).matches()
}
companion object { fun isDate(word: String): Boolean {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") }
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
} private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
private fun wordIdentifier(
word: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): Segment {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
return if (word.isEmpty()) {
RegularTextSegment(word)
} else if (images.contains(word)) {
ImageSegment(word)
} else if (urls.contains(word)) {
LinkSegment(word)
} else if (emojis.any { word.contains(it.key) }) {
EmojiSegment(word)
} else if (word.startsWith("lnbc", true)) {
InvoiceSegment(word)
} else if (word.startsWith("lnurl", true)) {
WithdrawSegment(word)
} else if (word.startsWith("cashuA", true)) {
CashuSegment(word)
} else if (emailMatcher.matches()) {
EmailSegment(word)
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
PhoneSegment(word)
} else if (startsWithNIP19Scheme(word)) {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
}
} else {
RegularTextSegment(word)
}
}
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
try {
if (matcher.find()) {
val index = matcher.group(1)?.toInt()
val suffix = matcher.group(2)
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
}
}
}
}
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
val hashtagMatcher = hashTagsPattern.matcher(word)
try {
if (hashtagMatcher.find()) {
val hashtag = hashtagMatcher.group(1)
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
}
}
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
} }
@Immutable open class Segment(val segmentText: String) @Immutable open class Segment(val segmentText: String)
@@ -332,27 +332,27 @@ class RichTextParser() {
@Immutable @Immutable
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : open class HashIndexSegment(segment: String, val hex: String, val extras: String?) :
Segment(segment) Segment(segment)
@Immutable @Immutable
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : class HashIndexUserSegment(segment: String, hex: String, extras: String?) :
HashIndexSegment(segment, hex, extras) HashIndexSegment(segment, hex, extras)
@Immutable @Immutable
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : class HashIndexEventSegment(segment: String, hex: String, extras: String?) :
HashIndexSegment(segment, hex, extras) HashIndexSegment(segment, hex, extras)
@Immutable @Immutable
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment) class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
@Immutable @Immutable
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) :
Segment(segment) Segment(segment)
@Immutable class RegularTextSegment(segment: String) : Segment(segment) @Immutable class RegularTextSegment(segment: String) : Segment(segment)
fun startsWithNIP19Scheme(word: String): Boolean { fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
} }
@@ -28,193 +28,193 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import java.util.Base64
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
@Immutable @Immutable
data class CashuToken( data class CashuToken(
val token: String, val token: String,
val mint: String, val mint: String,
val totalAmount: Long, val totalAmount: Long,
val proofs: JsonNode, val proofs: JsonNode,
) )
class CashuProcessor { class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> { fun parse(cashuToken: String): GenericLoadable<CashuToken> {
checkNotInMainThread() checkNotInMainThread()
try { try {
val base64token = cashuToken.replace("cashuA", "") val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token))) val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0) val token = cashu.get("token").get(0)
val proofs = token.get("proofs") val proofs = token.get("proofs")
val mint = token.get("mint").asText() val mint = token.get("mint").asText()
var totalAmount = 0L var totalAmount = 0L
for (proof in proofs) { for (proof in proofs) {
totalAmount += proof.get("amount").asLong() totalAmount += proof.get("amount").asLong()
} }
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
} catch (e: Exception) { } catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token") return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
} }
}
suspend fun melt( suspend fun melt(
token: CashuToken, token: CashuToken,
lud16: String, lud16: String,
onSuccess: (String, String) -> Unit, onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
) { ) {
checkNotInMainThread() checkNotInMainThread()
runCatching { runCatching {
LightningAddressResolver() LightningAddressResolver()
.lnAddressInvoice( .lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16, lnaddress = lud16,
// Make invoice and leave room for fees // Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000, milliSats = token.totalAmount * 1000,
message = "Redeem Cashu", message = "Calculate Fees for Cashu",
onSuccess = { invoice -> onSuccess = { baseInvoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context) feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context)
},
onProgress = {},
onError = onError,
context = context,
)
},
onError = onError,
context,
)
}, },
onProgress = {}, onProgress = {},
onError = onError, onError = onError,
context = context, context = context,
) )
}, }
onError = onError, }
context,
fun feeCalculator(
mintAddress: String,
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
try {
val client = HttpClient.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder().url(url).post(requestBody).build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost,
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
} catch (e: Exception) {
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
) )
},
onProgress = {},
onError = onError,
context = context,
)
}
}
fun feeCalculator(
mintAddress: String,
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
try {
val client = HttpClient.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder().url(url).post(requestBody).build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost,
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
},
)
} }
}
} catch (e: Exception) {
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
} }
}
private fun meltInvoice( private fun meltInvoice(
token: CashuToken, token: CashuToken,
invoice: String, invoice: String,
fees: Int, fees: Int,
onSuccess: (String, String) -> Unit, onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
) { ) {
try { try {
val client = HttpClient.getHttpClient() val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint val url = token.mint + "/melt" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode() val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs) jsonObject.put("proofs", token.proofs)
jsonObject.put("pr", invoice) jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType() val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType) val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder().url(url).post(requestBody).build() val request = Request.Builder().url(url).post(requestBody).build()
client.newCall(request).execute().use { client.newCall(request).execute().use {
val body = it.body.string() val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body) val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true" val successful = tree?.get("paid")?.asText() == "true"
if (successful) { if (successful) {
onSuccess( onSuccess(
context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_successful_redemption),
context.getString( context.getString(
R.string.cashu_successful_redemption_explainer, R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(), token.totalAmount.toString(),
fees.toString(), fees.toString(),
), ),
) )
} else { } else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError( onError(
context.getString(R.string.cashu_failed_redemption), context.getString(R.string.cashu_failed_redemption),
if (msg != null) { if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else { } else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg) context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
}, },
) )
}
}
} catch (e: Exception) {
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
} }
}
} catch (e: Exception) {
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
} }
}
} }
@@ -23,106 +23,109 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean { fun String.isUTF16Char(pos: Int): Boolean {
return Character.charCount(this.codePointAt(pos)) == 2 return Character.charCount(this.codePointAt(pos)) == 2
} }
fun String.firstFullCharOld(): String { fun String.firstFullCharOld(): String {
return when (this.length) { return when (this.length) {
0, 0,
1, -> return this 1,
2, -> return this
3, -> return if (isUTF16Char(0)) this.take(2) else this.take(1) 2,
else -> { 3,
val first = isUTF16Char(0) -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
val second = isUTF16Char(2) else -> {
if (first && second) { val first = isUTF16Char(0)
this.take(4) val second = isUTF16Char(2)
} else if (first) { if (first && second) {
this.take(2) this.take(4)
} else { } else if (first) {
this.take(1) this.take(2)
} } else {
this.take(1)
}
}
} }
}
} }
fun String.firstFullChar(): String { fun String.firstFullChar(): String {
var isInJoin = false var isInJoin = false
var hasHadSecondChance = false var hasHadSecondChance = false
var start = 0 var start = 0
var previousCharLength = 0 var previousCharLength = 0
var next: Int var next: Int
var codePoint: Int var codePoint: Int
var i = 0 var i = 0
while (i < this.length) { while (i < this.length) {
codePoint = codePointAt(i) codePoint = codePointAt(i)
// Skips if it starts with the join char 0x200D // Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) { if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1) next = offsetByCodePoints(i, 1)
start = next start = next
} else {
// If join, searches for the next char
if (codePoint == 0xFE0F) {} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
}
} else { } else {
hasHadSecondChance = false // If join, searches for the next char
if (codePoint == 0xFE0F) {
} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
}
} else {
hasHadSecondChance = false
}
isInJoin = false
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
} }
isInJoin = false i = next
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
} }
i = next // if ends in join, then seachers backwards until a char is found.
} if (isInJoin) {
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
// if ends in join, then seachers backwards until a char is found. i -= 1
if (isInJoin) { }
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
i -= 1
} }
}
return substring(start, start + previousCharLength) return substring(start, start + previousCharLength)
} }
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String { fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
if (length <= 2) { if (length <= 2) {
return firstFullChar() return firstFullChar()
}
if (this[0] == ':') {
// makes sure an emoji exists
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}
} }
}
return firstFullChar() if (this[0] == ':') {
// makes sure an emoji exists
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}
}
}
return firstFullChar()
} }
@@ -36,229 +36,230 @@ import java.io.IOException
import kotlin.math.roundToInt import kotlin.math.roundToInt
class FileHeader( class FileHeader(
val mimeType: String?, val mimeType: String?,
val hash: String, val hash: String,
val size: Int, val size: Int,
val dim: String?, val dim: String?,
val blurHash: String?, val blurHash: String?,
) { ) {
companion object { companion object {
suspend fun prepare( suspend fun prepare(
fileUrl: String, fileUrl: String,
mimeType: String?, mimeType: String?,
dimPrecomputed: String?, dimPrecomputed: String?,
onReady: (FileHeader) -> Unit, onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit, onError: (String?) -> Unit,
) { ) {
try { try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) { if (imageData != null) {
prepare(imageData, mimeType, dimPrecomputed, onReady, onError) prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else {
onError(null)
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError(e.message)
}
}
fun prepare(
data: ByteArray,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
) {
try {
val hash = CryptoUtils.sha256(data).toHexKey()
val size = data.size
val (blurHash, dim) =
if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height,
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
),
dim,
)
} else if (aspectRatio < 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
(9 * aspectRatio).roundToInt(),
9,
),
dim,
)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash =
mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height,
)
if (aspectRatio > 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
)
} else if (aspectRatio < 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
(9 * aspectRatio).roundToInt(),
9,
)
} else { } else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4) onError(null)
} }
} } catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
if (newDim != "0x0") { onError(e.message)
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
} }
} else { }
Pair(null, null)
}
onReady(FileHeader(mimeType, hash, size, dim, blurHash)) fun prepare(
} catch (e: Exception) { data: ByteArray,
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") mimeType: String?,
onError(e.message) dimPrecomputed: String?,
} onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit,
) {
try {
val hash = CryptoUtils.sha256(data).toHexKey()
val size = data.size
val (blurHash, dim) =
if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height,
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
),
dim,
)
} else if (aspectRatio < 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
(9 * aspectRatio).roundToInt(),
9,
),
dim,
)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash =
mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height,
)
if (aspectRatio > 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
)
} else if (aspectRatio < 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
(9 * aspectRatio).roundToInt(),
9,
)
} else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
}
}
if (newDim != "0x0") {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
}
} else {
Pair(null, null)
}
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError(e.message)
}
}
} }
}
} }
fun MediaMetadataRetriever.getThumbnail(): Bitmap? { fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
val raw: ByteArray? = getEmbeddedPicture() val raw: ByteArray? = getEmbeddedPicture()
if (raw != null) { if (raw != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw)) return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
}
} }
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val params = BitmapParams() val params = BitmapParams()
params.preferredConfig = Bitmap.Config.ARGB_8888 params.preferredConfig = Bitmap.Config.ARGB_8888
// Fall back to middle of video // Fall back to middle of video
// Note: METADATA_KEY_DURATION unit is in ms, not us. // Note: METADATA_KEY_DURATION unit is in ms, not us.
val thumbnailTimeUs: Long = val thumbnailTimeUs: Long =
(extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2 (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params) getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
} else {
null
}
} else { } else {
null null
} }
} else {
null
}
} }
fun MediaMetadataRetriever.prepareDimFromVideo(): String? { fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
val width = prepareVideoWidth() ?: return null val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null val height = prepareVideoHeight() ?: return null
return "${width}x$height" return "${width}x$height"
} }
fun MediaMetadataRetriever.prepareVideoWidth(): Int? { fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
return if (widthData.isNullOrEmpty()) { return if (widthData.isNullOrEmpty()) {
null null
} else { } else {
widthData.toInt() widthData.toInt()
} }
} }
fun MediaMetadataRetriever.prepareVideoHeight(): Int? { fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
return if (heightData.isNullOrEmpty()) { return if (heightData.isNullOrEmpty()) {
null null
} else { } else {
heightData.toInt() heightData.toInt()
} }
} }
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() { class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long { override fun getSize(): Long {
return imageData.size.toLong() return imageData.size.toLong()
}
@Throws(IOException::class)
override fun readAt(
position: Long,
buffer: ByteArray,
offset: Int,
size: Int,
): Int {
if (position >= imageData.size) {
return -1
} }
val newSize =
if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize) @Throws(IOException::class)
override fun readAt(
position: Long,
buffer: ByteArray,
offset: Int,
size: Int,
): Int {
if (position >= imageData.size) {
return -1
}
val newSize =
if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
return newSize imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
}
@Throws(IOException::class) override fun close() {} return newSize
}
@Throws(IOException::class)
override fun close() {}
} }
@@ -22,98 +22,98 @@ package com.vitorpamplona.amethyst.service
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException import java.io.IOException
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
import java.time.Duration import java.time.Duration
import kotlin.properties.Delegates import kotlin.properties.Delegates
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
object HttpClient { object HttpClient {
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L)
var proxyChangeListeners = ArrayList<() -> Unit>() var proxyChangeListeners = ArrayList<() -> Unit>()
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
var defaultHttpClient: OkHttpClient? = null var defaultHttpClient: OkHttpClient? = null
// fires off every time value of the property changes // fires off every time value of the property changes
private var internalProxy: Proxy? by private var internalProxy: Proxy? by
Delegates.observable(null) { _, oldValue, newValue -> Delegates.observable(null) { _, oldValue, newValue ->
if (oldValue != newValue) { if (oldValue != newValue) {
proxyChangeListeners.forEach { it() } proxyChangeListeners.forEach { it() }
} }
}
fun start(proxy: Proxy?) {
if (internalProxy != proxy) {
this.internalProxy = proxy
this.defaultHttpClient = getHttpClient()
}
} }
fun start(proxy: Proxy?) { fun changeTimeouts(timeout: Duration) {
if (internalProxy != proxy) { Log.d("HttpClient", "Changing timeout to: $timeout")
this.internalProxy = proxy if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultHttpClient = getHttpClient() this.defaultTimeout = timeout
this.defaultHttpClient = getHttpClient()
}
} }
}
fun changeTimeouts(timeout: Duration) { fun getHttpClient(timeout: Duration): OkHttpClient {
Log.d("HttpClient", "Changing timeout to: $timeout") val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
if (this.defaultTimeout.seconds != timeout.seconds) { val duration = Duration.ofSeconds(seconds)
this.defaultTimeout = timeout return OkHttpClient.Builder()
this.defaultHttpClient = getHttpClient() .proxy(internalProxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor())
.followRedirects(true)
.followSslRedirects(true)
.build()
} }
}
fun getHttpClient(timeout: Duration): OkHttpClient { class DefaultContentTypeInterceptor : Interceptor {
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds @Throws(IOException::class)
val duration = Duration.ofSeconds(seconds) override fun intercept(chain: Interceptor.Chain): Response {
return OkHttpClient.Builder() val originalRequest: Request = chain.request()
.proxy(internalProxy) val requestWithUserAgent: Request =
.readTimeout(duration) originalRequest
.connectTimeout(duration) .newBuilder()
.writeTimeout(duration) .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.addInterceptor(DefaultContentTypeInterceptor()) .build()
.followRedirects(true) return chain.proceed(requestWithUserAgent)
.followSslRedirects(true) }
.build()
}
class DefaultContentTypeInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.build()
return chain.proceed(requestWithUserAgent)
} }
}
fun getHttpClientForRelays(): OkHttpClient { fun getHttpClientForRelays(): OkHttpClient {
if (this.defaultHttpClient == null) { if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout) this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
} }
return defaultHttpClient!!
}
fun getHttpClient(): OkHttpClient { fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) { if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout) this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
} }
return defaultHttpClient!!
}
fun getProxy(): Proxy? { fun getProxy(): Proxy? {
return internalProxy return internalProxy
} }
fun initProxy( fun initProxy(
useProxy: Boolean, useProxy: Boolean,
hostname: String, hostname: String,
port: Int, port: Int,
): Proxy? { ): Proxy? {
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
} }
} }
@@ -32,81 +32,81 @@ import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) { class LocationUtil(context: Context) {
companion object { companion object {
const val MIN_TIME: Long = 1000L const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
val providerState = mutableStateOf(false)
val isStart: MutableState<Boolean> = mutableStateOf(false)
private val locHandlerThread = HandlerThread("LocationUtil Thread")
init {
locHandlerThread.start()
}
@SuppressLint("MissingPermission")
fun start(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
) {
locationListener().let {
locationListener = it
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
it,
locHandlerThread.looper,
)
} }
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isStart.value = true
}
fun stop() { private val locationManager =
locationListener?.let { locationManager.removeUpdates(it) } context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
isStart.value = false private var locationListener: LocationListener? = null
}
private fun locationListener() = val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
object : LocationListener { val providerState = mutableStateOf(false)
override fun onLocationChanged(location: Location) { val isStart: MutableState<Boolean> = mutableStateOf(false)
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) { private val locHandlerThread = HandlerThread("LocationUtil Thread")
providerState.value = true
}
override fun onProviderDisabled(provider: String) { init {
providerState.value = false locHandlerThread.start()
}
} }
@SuppressLint("MissingPermission")
fun start(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
) {
locationListener().let {
locationListener = it
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
it,
locHandlerThread.looper,
)
}
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isStart.value = true
}
fun stop() {
locationListener?.let { locationManager.removeUpdates(it) }
isStart.value = false
}
private fun locationListener() =
object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderDisabled(provider: String) {
providerState.value = false
}
}
} }
class ReverseGeoLocationUtil { class ReverseGeoLocationUtil {
suspend fun execute( suspend fun execute(
location: Location, location: Location,
context: Context, context: Context,
): String? { ): String? {
return try { return try {
Geocoder(context) Geocoder(context)
.getFromLocation(location.latitude, location.longitude, 1) .getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull() ?.firstOrNull()
?.let { address -> ?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode) listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ") .joinToString(", ")
}
} catch (e: Exception) {
e.printStackTrace()
return null
} }
} catch (e: Exception) {
e.printStackTrace()
return null
} }
}
} }
@@ -24,9 +24,9 @@ import android.os.Looper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
fun checkNotInMainThread() { fun checkNotInMainThread() {
if (BuildConfig.DEBUG && isMainThread()) { if (BuildConfig.DEBUG && isMainThread()) {
throw OnMainThreadException("It should not be in the MainThread") throw OnMainThreadException("It should not be in the MainThread")
} }
} }
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
@@ -30,121 +30,120 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
class Nip05NostrAddressVerifier() { class Nip05NostrAddressVerifier() {
fun assembleUrl(nip05address: String): String? { fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@") val parts = nip05address.trim().split("@")
if (parts.size == 2) { if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
} }
if (parts.size == 1) { if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_" return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
} }
return null suspend fun fetchNip05Json(
} nip05: String,
onSuccess: (String) -> Unit,
suspend fun fetchNip05Json( onError: (String) -> Unit,
nip05: String, ) = withContext(Dispatchers.IO) {
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) =
withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return@withContext
}
try {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
}
override fun onFailure(
call: Call,
e: java.io.IOException,
) {
onError(
"Could not resolve $url. Check if the server is up and if the address $nip05 is correct",
)
e.printStackTrace()
}
},
)
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
suspend fun verifyNip05(
nip05: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests
checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
checkNotInMainThread() checkNotInMainThread()
// NIP05 usernames are case insensitive, but JSON properties are not val url = assembleUrl(nip05)
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url =
try {
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val parts = nip05.split("@") if (url == null) {
val user = onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
if (parts.size == 2) { return@withContext
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
} }
},
onError = onError, try {
) val request =
} Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
}
override fun onFailure(
call: Call,
e: java.io.IOException,
) {
onError(
"Could not resolve $url. Check if the server is up and if the address $nip05 is correct",
)
e.printStackTrace()
}
},
)
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
suspend fun verifyNip05(
nip05: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests
checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
checkNotInMainThread()
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url =
try {
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val parts = nip05.split("@")
val user =
if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
}
},
onError = onError,
)
}
} }
@@ -23,107 +23,107 @@ package com.vitorpamplona.amethyst.service
import android.util.Log import android.util.Log
import android.util.LruCache import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.amethyst.model.RelayInformation
import java.io.IOException
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever { object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100) val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
val retriever = Nip11Retriever() val retriever = Nip11Retriever()
suspend fun loadRelayInfo( suspend fun loadRelayInfo(
dirtyUrl: String, dirtyUrl: String,
onInfo: (RelayInformation) -> Unit, onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) { ) {
val url = retriever.cleanUrl(dirtyUrl) val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url) val doc = relayInformationDocumentCache.get(url)
if (doc != null) { if (doc != null) {
onInfo(doc) onInfo(doc)
} else { } else {
Nip11Retriever() Nip11Retriever()
.loadRelayInfo( .loadRelayInfo(
url, url,
dirtyUrl, dirtyUrl,
onInfo = { onInfo = {
relayInformationDocumentCache.put(url, it) relayInformationDocumentCache.put(url, it)
onInfo(it) onInfo(it)
}, },
onError, onError,
) )
}
} }
}
} }
class Nip11Retriever { class Nip11Retriever {
enum class ErrorCode { enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL, FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER, FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT, FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS, FAIL_WITH_HTTP_STATUS,
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
} else {
"https://$dirtyUrl"
} }
}
suspend fun loadRelayInfo( fun cleanUrl(dirtyUrl: String): String {
url: String, return if (dirtyUrl.contains("://")) {
dirtyUrl: String, dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
onInfo: (RelayInformation) -> Unit, } else {
onError: (String, ErrorCode, String?) -> Unit, "https://$dirtyUrl"
) { }
try { }
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build() suspend fun loadRelayInfo(
url: String,
HttpClient.getHttpClient() dirtyUrl: String,
.newCall(request) onInfo: (RelayInformation) -> Unit,
.enqueue( onError: (String, ErrorCode, String?) -> Unit,
object : Callback { ) {
override fun onResponse( try {
call: Call, val request: Request =
response: Response, Request.Builder().header("Accept", "application/nostr+json").url(url).build()
) {
checkNotInMainThread() HttpClient.getHttpClient()
response.use { .newCall(request)
val body = it.body.string() .enqueue(
try { object : Callback {
if (it.isSuccessful) { override fun onResponse(
onInfo(RelayInformation.fromJson(body)) call: Call,
} else { response: Response,
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) ) {
} checkNotInMainThread()
} catch (e: Exception) { response.use {
Log.e( val body = it.body.string()
"RelayInfoFail", try {
"Resulting Message from Relay $dirtyUrl in not parseable: $body", if (it.isSuccessful) {
e, onInfo(RelayInformation.fromJson(body))
) } else {
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
} }
} } catch (e: Exception) {
} Log.e(
"RelayInfoFail",
override fun onFailure( "Resulting Message from Relay $dirtyUrl in not parseable: $body",
call: Call, e,
e: IOException, )
) { onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) }
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) }
} }
},
) override fun onFailure(
} catch (e: Exception) { call: Call,
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) e: IOException,
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) ) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
},
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
} }
}
} }
@@ -25,31 +25,31 @@ import java.util.regex.Pattern
@Immutable @Immutable
class Nip30CustomEmoji { class Nip30CustomEmoji {
val customEmojiPattern: Pattern = val customEmojiPattern: Pattern =
Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> { fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input) val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>() val list = mutableListOf<String>()
while (matcher.find()) { while (matcher.find()) {
list.add(matcher.group()) list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
} }
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
} }
@@ -24,21 +24,21 @@ import java.net.URI
import java.net.URLDecoder import java.net.URLDecoder
class Nip44UrlParser { class Nip44UrlParser {
fun parse(url: String): Map<String, String> { fun parse(url: String): Map<String, String> {
return try { return try {
fragments(URI(url)) fragments(URI(url))
} catch (e: Exception) { } catch (e: Exception) {
emptyMap() emptyMap()
}
} }
}
private fun fragments(uri: URI): Map<String, String> { private fun fragments(uri: URI): Map<String, String> {
if (uri.rawFragment == null) return emptyMap() if (uri.rawFragment == null) return emptyMap()
return uri.rawFragment.split('&').associate { keyValuePair -> return uri.rawFragment.split('&').associate { keyValuePair ->
val parts = keyValuePair.split('=') val parts = keyValuePair.split('=')
val name = parts.firstOrNull() ?: "" val name = parts.firstOrNull() ?: ""
val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: "" val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
Pair(name, value) Pair(name, value)
}
} }
}
} }
@@ -27,27 +27,27 @@ import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready. // Rename to the corect nip number when ready.
object Nip47WalletConnectParser { object Nip47WalletConnectParser {
fun parse(uri: String): Nip47URI { fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri) val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code") throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex =
try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Nip47URI(pubkeyHex, relay, secret)
} }
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex =
try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Nip47URI(pubkeyHex, relay, secret)
}
} }
@@ -27,85 +27,85 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.Request import okhttp3.Request
object Nip96MediaServers { object Nip96MediaServers {
val DEFAULT = val DEFAULT =
listOf( listOf(
ServerName("Nostr.Build", "https://nostr.build"), ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"), ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"), ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"), ServerName("Sove", "https://sove.rent"),
ServerName("Sovbit", "https://files.sovbit.host"), ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat"), ServerName("Void.cat", "https://void.cat"),
) )
data class ServerName(val name: String, val baseUrl: String) data class ServerName(val name: String, val baseUrl: String)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf() val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(url: String): Nip96Retriever.ServerInfo { suspend fun load(url: String): Nip96Retriever.ServerInfo {
val cached = cache[url] val cached = cache[url]
if (cached != null) return cached if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url) val fetched = Nip96Retriever().loadInfo(url)
cache[url] = fetched cache[url] = fetched
return fetched return fetched
} }
} }
class Nip96Retriever { class Nip96Retriever {
data class ServerInfo( data class ServerInfo(
@JsonProperty("api_url") val apiUrl: String, @JsonProperty("api_url") val apiUrl: String,
@JsonProperty("download_url") val downloadUrl: String? = null, @JsonProperty("download_url") val downloadUrl: String? = null,
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(), @JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val tosUrl: String? = null, @JsonProperty("tos_url") val tosUrl: String? = null,
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(), @JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(), @JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(),
) )
data class Plan( data class Plan(
@JsonProperty("name") val name: String? = null, @JsonProperty("name") val name: String? = null,
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
@JsonProperty("url") val url: String? = null, @JsonProperty("url") val url: String? = null,
@JsonProperty("max_byte_size") val maxByteSize: Long? = null, @JsonProperty("max_byte_size") val maxByteSize: Long? = null,
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(), @JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations") @JsonProperty("media_transformations")
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(), val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
) )
fun parse(body: String): ServerInfo { fun parse(body: String): ServerInfo {
val mapper = val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java) return mapper.readValue(body, ServerInfo::class.java)
} }
suspend fun loadInfo(baseUrl: String): ServerInfo { suspend fun loadInfo(baseUrl: String): ServerInfo {
checkNotInMainThread() checkNotInMainThread()
val request: Request = val request: Request =
Request.Builder() Request.Builder()
.header("Accept", "application/nostr+json") .header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build() .build()
HttpClient.getHttpClient().newCall(request).execute().use { response -> HttpClient.getHttpClient().newCall(request).execute().use { response ->
checkNotInMainThread() checkNotInMainThread()
response.use { response.use {
val body = it.body.string() val body = it.body.string()
try { try {
if (it.isSuccessful) { if (it.isSuccessful) {
return parse(body) return parse(body)
} else { } else {
throw RuntimeException( throw RuntimeException(
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e throw e
} }
} }
}
} }
}
} }
typealias PlanName = String typealias PlanName = String
@@ -30,9 +30,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -42,276 +39,279 @@ import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("") fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(val account: Account?) { class Nip96Uploader(val account: Account?) {
suspend fun uploadImage( suspend fun uploadImage(
uri: Uri, uri: Uri,
contentType: String?, contentType: String?,
size: Long?, size: Long?,
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
server: Nip96MediaServers.ServerName, server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver, contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
): PartialEvent { ): PartialEvent {
val serverInfo = val serverInfo =
Nip96Retriever() Nip96Retriever()
.loadInfo( .loadInfo(
server.baseUrl, server.baseUrl,
)
return uploadImage(
uri,
contentType,
size,
alt,
sensitiveContent,
serverInfo,
contentResolver,
onProgress,
) )
}
return uploadImage( suspend fun uploadImage(
uri, uri: Uri,
contentType, contentType: String?,
size, size: Long?,
alt, alt: String?,
sensitiveContent, sensitiveContent: String?,
serverInfo, server: Nip96Retriever.ServerInfo,
contentResolver, contentResolver: ContentResolver,
onProgress, onProgress: (percentage: Float) -> Unit,
) ): PartialEvent {
} checkNotInMainThread()
suspend fun uploadImage( val myContentType = contentType ?: contentResolver.getType(uri)
uri: Uri, val imageInputStream = contentResolver.openInputStream(uri)
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri) val length =
val imageInputStream = contentResolver.openInputStream(uri) size
?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
}
?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0
val length = checkNotNull(imageInputStream) { "Can't open the image input stream" }
size
?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
}
?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0
checkNotNull(imageInputStream) { "Can't open the image input stream" } return uploadImage(
imageInputStream,
length,
myContentType,
alt,
sensitiveContent,
server,
onProgress,
)
}
return uploadImage( suspend fun uploadImage(
imageInputStream, inputStream: InputStream,
length, length: Long,
myContentType, contentType: String?,
alt, alt: String?,
sensitiveContent, sensitiveContent: String?,
server, server: Nip96Retriever.ServerInfo,
onProgress, onProgress: (percentage: Float) -> Unit,
) ): PartialEvent {
} checkNotInMainThread()
suspend fun uploadImage( val fileName = randomChars()
inputStream: InputStream, val extension =
length: Long, contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
checkNotInMainThread()
val fileName = randomChars() val client = HttpClient.getHttpClient()
val extension = val requestBody: RequestBody
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val requestBuilder = Request.Builder()
val client = HttpClient.getHttpClient() requestBody =
val requestBody: RequestBody MultipartBody.Builder()
val requestBuilder = Request.Builder() .setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
requestBody = override fun contentLength() = length
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
},
)
.build()
override fun writeTo(sink: BufferedSink) { nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
inputStream.source().use(sink::writeAll)
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl)
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
}
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
} }
},
)
.build()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl)
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
}
} }
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
} }
}
suspend fun delete( suspend fun delete(
hash: String, hash: String,
contentType: String?, contentType: String?,
server: Nip96Retriever.ServerInfo, server: Nip96Retriever.ServerInfo,
): Boolean { ): Boolean {
val extension = val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient() val client = HttpClient.getHttpClient()
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension") println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request = val request =
requestBuilder requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete() .delete()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
response.body.use { body -> response.body.use { body ->
val str = body.string() val str = body.string()
val result = parseDeleteResults(str) val result = parseDeleteResults(str)
return result.status == "success" return result.status == "success"
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
} }
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
} }
}
private suspend fun waitProcessing( private suspend fun waitProcessing(
result: Nip96Result, result: Nip96Result,
server: Nip96Retriever.ServerInfo, server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
): PartialEvent { ): PartialEvent {
val client = HttpClient.getHttpClient() val client = HttpClient.getHttpClient()
var currentResult = result var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
onProgress((currentResult.percentage ?: 100) / 100f) onProgress((currentResult.percentage ?: 100) / 100f)
val request: Request = val request: Request =
Request.Builder() Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(result.processingUrl) .url(result.processingUrl)
.build() .build()
client.newCall(request).execute().use { client.newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful) {
it.body.use { currentResult = parseResults(it.string()) } it.body.use { currentResult = parseResults(it.string()) }
}
}
delay(500)
} }
} onProgress((currentResult.percentage ?: 100) / 100f)
delay(500) val nip94 = currentResult.nip94Event
}
onProgress((currentResult.percentage ?: 100) / 100f)
val nip94 = currentResult.nip94Event if (nip94 != null) {
return nip94
if (nip94 != null) { } else {
return nip94 throw RuntimeException("Error waiting for processing. Final result is unavailable")
} else { }
throw RuntimeException("Error waiting for processing. Final result is unavailable")
}
}
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
}
}
fun nip98Header(
url: String,
method: String,
file: ByteArray? = null,
onReady: (String?) -> Unit,
) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
} }
myAccount.createHTTPAuthorization(url, method, file) { suspend fun nip98Header(url: String): String? {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) return withTimeoutOrNull(5000) {
onReady("Nostr $encodedNIP98Event") suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
}
} }
}
data class DeleteResult( fun nip98Header(
val status: String?, url: String,
val message: String?, method: String,
) file: ByteArray? = null,
onReady: (String?) -> Unit,
) {
val myAccount = account
private fun parseDeleteResults(body: String): DeleteResult { if (myAccount == null) {
val mapper = onReady(null)
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) return
return mapper.readValue(body, DeleteResult::class.java) }
}
data class Nip96Result( myAccount.createHTTPAuthorization(url, method, file) {
val status: String? = null, val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
val message: String? = null, onReady("Nostr $encodedNIP98Event")
@JsonProperty("processing_url") val processingUrl: String? = null, }
val percentage: Int? = null, }
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
)
class PartialEvent( data class DeleteResult(
val tags: Array<Array<String>>? = null, val status: String?,
val content: String? = null, val message: String?,
) )
private fun parseResults(body: String): Nip96Result { private fun parseDeleteResults(body: String): DeleteResult {
val mapper = val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java) return mapper.readValue(body, DeleteResult::class.java)
} }
data class Nip96Result(
val status: String? = null,
val message: String? = null,
@JsonProperty("processing_url") val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null,
)
private fun parseResults(body: String): Nip96Result {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java)
}
} }
@@ -58,289 +58,290 @@ import com.vitorpamplona.quartz.utils.TimeUtils
// TODO: Migrate this to a property of AccountVi // TODO: Migrate this to a property of AccountVi
object NostrAccountDataSource : NostrDataSource("AccountData") { object NostrAccountDataSource : NostrDataSource("AccountData") {
lateinit var account: Account lateinit var account: Account
var otherAccounts = listOf<HexKey>() var otherAccounts = listOf<HexKey>()
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>() val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountContactListFilter(): TypedFilter { fun createAccountContactListFilter(): TypedFilter {
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(ContactListEvent.KIND), kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex), authors = listOf(account.userProfile().pubkeyHex),
limit = 1, limit = 1,
), ),
)
}
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5,
),
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
limit = 100,
),
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10,
),
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList,
),
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
)
}
fun createNotificationFilter(): TypedFilter {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
PollNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = since,
),
)
}
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
),
)
val accountChannel = requestNewChannel { time, relayUrl ->
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultNotificationFollowList.value,
relayUrl,
time,
)
} else {
hasLoadedTheBasics[account.userProfile()] = true
invalidateFilters()
}
}
override fun consume(
event: Event,
relay: Relay,
) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (event is SealedGossipEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
checkNotInMainThread()
super.markAsSeenOnRelay(eventId, relay)
val note = LocalCache.getNoteIfExists(eventId) ?: return
val privKey = account.keyPair.privKey ?: return
val noteEvent = note.event ?: return
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
}
private fun markInnerAsSeenOnRelay(
noteEvent: EventInterface,
privKey: ByteArray,
relay: Relay,
) {
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
if (noteEvent is GiftWrapEvent) {
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) }
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, privKey, relay)
}
}
}
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everything about the user logged in
accountChannel.typedFilters =
listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
.ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters =
listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter(),
)
.ifEmpty { null }
}
}
override fun auth(
relay: Relay,
challenge: String,
) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
account.createAuthEvent(relay, challenge) {
Client.send(
it,
relay.url,
) )
}
} }
}
override fun notify( fun createAccountMetadataFilter(): TypedFilter {
relay: Relay, return TypedFilter(
description: String, types = COMMON_FEED_TYPES,
) { filter =
super.notify(relay, description) JsonFilter(
kinds = listOf(MetadataEvent.KIND),
if (this::account.isInitialized) { authors = listOf(account.userProfile().pubkeyHex),
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) limit = 1,
),
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5,
),
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
limit = 100,
),
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10,
),
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList,
),
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
)
}
fun createNotificationFilter(): TypedFilter {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
PollNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = since,
),
)
}
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
),
)
val accountChannel =
requestNewChannel { time, relayUrl ->
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultNotificationFollowList.value,
relayUrl,
time,
)
} else {
hasLoadedTheBasics[account.userProfile()] = true
invalidateFilters()
}
}
override fun consume(
event: Event,
relay: Relay,
) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (event is SealedGossipEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
checkNotInMainThread()
super.markAsSeenOnRelay(eventId, relay)
val note = LocalCache.getNoteIfExists(eventId) ?: return
val privKey = account.keyPair.privKey ?: return
val noteEvent = note.event ?: return
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
}
private fun markInnerAsSeenOnRelay(
noteEvent: EventInterface,
privKey: ByteArray,
relay: Relay,
) {
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
if (noteEvent is GiftWrapEvent) {
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) }
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, privKey, relay)
}
}
}
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everything about the user logged in
accountChannel.typedFilters =
listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
.ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters =
listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter(),
)
.ifEmpty { null }
}
}
override fun auth(
relay: Relay,
challenge: String,
) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
account.createAuthEvent(relay, challenge) {
Client.send(
it,
relay.url,
)
}
}
}
override fun notify(
relay: Relay,
description: String,
) {
super.notify(relay, description)
if (this::account.isInitialized) {
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description))
}
} }
}
} }
@@ -31,89 +31,89 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null var account: Account? = null
var channel: Channel? = null var channel: Channel? = null
fun loadMessagesBetween( fun loadMessagesBetween(
account: Account, account: Account,
channel: Channel, channel: Channel,
) { ) {
this.account = account this.account = account
this.channel = channel this.channel = channel
resetFilters() resetFilters()
}
fun clear() {
account = null
channel = null
}
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
} }
return null
}
fun createMessagesToChannelFilter(): TypedFilter? { fun clear() {
if (channel is PublicChatChannel) { account = null
return TypedFilter( channel = null
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
} }
return null
}
val messagesChannel = requestNewChannel() fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
override fun updateChannelFilters() { if (channel is PublicChatChannel) {
messagesChannel.typedFilters = // Brings on messages by the user from all other relays.
listOfNotNull( // Since we ship with write to public, read from private only
createMessagesToChannelFilter(), // this guarantees that messages from the author do not disappear.
createMessagesByMeToChannelFilter(), return TypedFilter(
) types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
.ifEmpty { null } filter =
} JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
),
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
),
)
}
return null
}
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.typedFilters =
listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter(),
)
.ifEmpty { null }
}
} }
@@ -29,80 +29,81 @@ import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") { object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account lateinit var account: Account
private var withRoom: ChatroomKey? = null private var withRoom: ChatroomKey? = null
private val latestEOSEs = EOSEAccount() private val latestEOSEs = EOSEAccount()
fun loadMessagesBetween( fun loadMessagesBetween(
accountIn: Account, accountIn: Account,
withRoom: ChatroomKey, withRoom: ChatroomKey,
) { ) {
this.account = accountIn this.account = accountIn
this.withRoom = withRoom this.withRoom = withRoom
resetFilters() resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.map { it },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(withRoom.hashCode().toString())
?.relayList,
),
)
} else {
null
} }
}
fun createMessagesFromMeFilter(): TypedFilter? { fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withRoom val myPeer = withRoom
return if (myPeer != null) { return if (myPeer != null) {
TypedFilter( TypedFilter(
types = setOf(FeedType.PRIVATE_DMS), types = setOf(FeedType.PRIVATE_DMS),
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(PrivateDmEvent.KIND), kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex), authors = myPeer.users.map { it },
tags = mapOf("p" to myPeer.users.map { it }), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = since =
latestEOSEs.users[account.userProfile()] latestEOSEs.users[account.userProfile()]
?.followList ?.followList
?.get(withRoom.hashCode().toString()) ?.get(withRoom.hashCode().toString())
?.relayList, ?.relayList,
), ),
) )
} else { } else {
null null
}
} }
}
fun clearEOSEs(account: Account) { fun createMessagesFromMeFilter(): TypedFilter? {
latestEOSEs.removeDataFor(account.userProfile()) val myPeer = withRoom
}
val inandoutChannel = requestNewChannel { time, relayUrl -> return if (myPeer != null) {
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) TypedFilter(
} types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(withRoom.hashCode().toString())
?.relayList,
),
)
} else {
null
}
}
override fun updateChannelFilters() { fun clearEOSEs(account: Account) {
inandoutChannel.typedFilters = latestEOSEs.removeDataFor(account.userProfile())
listOfNotNull( }
createMessagesToMeFilter(),
createMessagesFromMeFilter(), val inandoutChannel =
) requestNewChannel { time, relayUrl ->
.ifEmpty { null } latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
} }
override fun updateChannelFilters() {
inandoutChannel.typedFilters =
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
)
.ifEmpty { null }
}
} }
@@ -32,124 +32,125 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account lateinit var account: Account
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
val chatRoomList = "ChatroomList" val chatRoomList = "ChatroomList"
fun createMessagesToMeFilter() = fun createMessagesToMeFilter() =
TypedFilter( TypedFilter(
types = setOf(FeedType.PRIVATE_DMS), types = setOf(FeedType.PRIVATE_DMS),
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(PrivateDmEvent.KIND), kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
), ),
)
fun createMessagesFromMeFilter() =
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
fun createChannelsCreatedbyMeFilter() =
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
fun createMyChannelsFilter(): TypedFilter? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
}
fun createLastChannelInfoFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
),
)
}
}
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
// Remember to consider spam that is being removed from the UI
limit = 50,
),
)
}
}
val chatroomListChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
override fun updateChannelFilters() {
val list =
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter(),
)
chatroomListChannel.typedFilters =
listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter(),
) )
.flatten()
.ifEmpty { null } fun createMessagesFromMeFilter() =
} TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
fun createChannelsCreatedbyMeFilter() =
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
fun createMyChannelsFilter(): TypedFilter? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
),
)
}
fun createLastChannelInfoFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
),
)
}
}
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
// Remember to consider spam that is being removed from the UI
limit = 50,
),
)
}
}
val chatroomListChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
override fun updateChannelFilters() {
val list =
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter(),
)
chatroomListChannel.typedFilters =
listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter(),
)
.flatten()
.ifEmpty { null }
}
} }
@@ -28,40 +28,40 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") { object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null private var communityToWatch: AddressableNote? = null
private fun createLoadCommunityFilter(): TypedFilter? { private fun createLoadCommunityFilter(): TypedFilter? {
val myCommunityToWatch = communityToWatch ?: return null val myCommunityToWatch = communityToWatch ?: return null
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
authors = authors =
community community
.moderators() .moderators()
.map { it.key } .map { it.key }
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)), .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
tags = tags =
mapOf( mapOf(
"a" to listOf(myCommunityToWatch.address.toTag()), "a" to listOf(myCommunityToWatch.address.toTag()),
), ),
kinds = listOf(CommunityPostApprovalEvent.KIND), kinds = listOf(CommunityPostApprovalEvent.KIND),
limit = 500, limit = 500,
), ),
) )
} }
val loadCommunityChannel = requestNewChannel() val loadCommunityChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
} }
fun loadCommunity(note: AddressableNote?) { fun loadCommunity(note: AddressableNote?) {
communityToWatch = note communityToWatch = note
invalidateFilters() invalidateFilters()
} }
} }
@@ -28,302 +28,302 @@ import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
abstract class NostrDataSource(val debugName: String) { abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>() private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int) data class Counter(var counter: Int)
private var eventCounter = mapOf<String, Counter>() private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean() var changingFilters = AtomicBoolean()
private var active: Boolean = false private var active: Boolean = false
fun printCounter() { fun printCounter() {
eventCounter.forEach { eventCounter.forEach {
Log.d( Log.d(
"STATE DUMP ${this.javaClass.simpleName}", "STATE DUMP ${this.javaClass.simpleName}",
"Received Events ${it.key}: ${it.value.counter}", "Received Events ${it.key}: ${it.value.counter}",
) )
}
} }
}
private val clientListener = private val clientListener =
object : Client.Listener() { object : Client.Listener() {
override fun onEvent( override fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
) {
if (subscriptions.containsKey(subscriptionId)) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
}
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, relay)
}
}
}
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.e(
// this@NostrDataSource.javaClass.simpleName,
// "Relay OnError ${relay.url}: ${error.message}"
// )
// }
}
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
subscriptionId: String?,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId}
// ${type.name}")
// }
if (
type == Relay.StateType.EOSE &&
subscriptionId != null &&
subscriptions.containsKey(subscriptionId)
) {
markAsEOSE(subscriptionId, relay)
}
}
override fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
) {
if (success) {
markAsSeenOnRelay(eventId, relay)
}
}
override fun onAuth(
relay: Relay,
challenge: String,
) {
auth(relay, challenge)
}
override fun onNotify(
relay: Relay,
description: String,
) {
notify(relay, description)
}
}
init {
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe")
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
active = true
resetFilters()
}
open fun stop() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
GlobalScope.launch(Dispatchers.IO) {
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
}
open fun stopSync() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
}
fun dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
scope.launch(Dispatchers.IO) {
bundler.invalidate {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
}
fun resetFilters() {
scope.launch(Dispatchers.IO) { resetFiltersSuspend() }
}
fun resetFiltersSuspend() {
println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active")
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
changingFilters.getAndSet(true)
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
val isActive = Client.isActive(updatedSubscription.id)
if (!isActive && updatedSubscriptionNewFilters != null) {
// Filter was removed from the active list
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
if (currentFilters.containsKey(updatedSubscription.id)) {
if (updatedSubscriptionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id,
updatedSubscriptionNewFilters,
)
}
}
}
} else {
if (updatedSubscriptionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
}
}
}
}
}
changingFilters.getAndSet(false)
}
open fun consume(
event: Event, event: Event,
subscriptionId: String,
relay: Relay, relay: Relay,
afterEOSE: Boolean, ) {
) { LocalCache.verifyAndConsume(event, relay)
if (subscriptions.containsKey(subscriptionId)) { }
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
}
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}") open fun markAsSeenOnRelay(
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, relay)
}
}
}
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.e(
// this@NostrDataSource.javaClass.simpleName,
// "Relay OnError ${relay.url}: ${error.message}"
// )
// }
}
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
subscriptionId: String?,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId}
// ${type.name}")
// }
if (
type == Relay.StateType.EOSE &&
subscriptionId != null &&
subscriptions.containsKey(subscriptionId)
) {
markAsEOSE(subscriptionId, relay)
}
}
override fun onSendResponse(
eventId: String, eventId: String,
success: Boolean,
message: String,
relay: Relay, relay: Relay,
) { ) {
if (success) { LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
markAsSeenOnRelay(eventId, relay) }
}
}
override fun onAuth( open fun markAsEOSE(
subscriptionId: String,
relay: Relay,
) {
subscriptions[subscriptionId]?.updateEOSE(
// in case people's clock is slighly off.
TimeUtils.oneMinuteAgo(),
relay.url,
)
}
abstract fun updateChannelFilters()
open fun auth(
relay: Relay, relay: Relay,
challenge: String, challenge: String,
) { ) = Unit
auth(relay, challenge)
}
override fun onNotify( open fun notify(
relay: Relay, relay: Relay,
description: String, description: String,
) { ) = Unit
notify(relay, description)
}
}
init {
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe")
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
active = true
resetFilters()
}
open fun stop() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
GlobalScope.launch(Dispatchers.IO) {
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
}
open fun stopSync() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
}
fun dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
scope.launch(Dispatchers.IO) {
bundler.invalidate {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
}
fun resetFilters() {
scope.launch(Dispatchers.IO) { resetFiltersSuspend() }
}
fun resetFiltersSuspend() {
println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active")
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
changingFilters.getAndSet(true)
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
val isActive = Client.isActive(updatedSubscription.id)
if (!isActive && updatedSubscriptionNewFilters != null) {
// Filter was removed from the active list
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
if (currentFilters.containsKey(updatedSubscription.id)) {
if (updatedSubscriptionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id,
updatedSubscriptionNewFilters,
)
}
}
}
} else {
if (updatedSubscriptionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
}
}
}
}
}
changingFilters.getAndSet(false)
}
open fun consume(
event: Event,
relay: Relay,
) {
LocalCache.verifyAndConsume(event, relay)
}
open fun markAsSeenOnRelay(
eventId: String,
relay: Relay,
) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
open fun markAsEOSE(
subscriptionId: String,
relay: Relay,
) {
subscriptions[subscriptionId]?.updateEOSE(
// in case people's clock is slighly off.
TimeUtils.oneMinuteAgo(),
relay.url,
)
}
abstract fun updateChannelFilters()
open fun auth(
relay: Relay,
challenge: String,
) = Unit
open fun notify(
relay: Relay,
description: String,
) = Unit
} }
@@ -39,384 +39,385 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
var job: Job? = null var job: Job? = null
override fun start() { override fun start() {
job?.cancel() job?.cancel()
job = job =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
account.liveDiscoveryFollowLists.collect { account.liveDiscoveryFollowLists.collect {
if (this@NostrDiscoveryDataSource::account.isInitialized) { if (this@NostrDiscoveryDataSource::account.isInitialized) {
invalidateFilters() invalidateFilters()
} }
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"t" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"g" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
authors = follows,
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
if (followChats.isNotEmpty()) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
} else {
null
},
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
val discoveryFeedChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
} }
}
super.start()
}
override fun stop() { override fun updateChannelFilters() {
super.stop() discoveryFeedChannel.typedFilters =
job?.cancel() createLiveStreamFilter()
} .plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
fun createMarketplaceFilter(): List<TypedFilter> { .plus(
val follows = account.liveDiscoveryFollowLists.value?.users?.toList() listOfNotNull(
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() createLiveStreamTagsFilter(),
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
return listOfNotNull( createCommunitiesTagsFilter(),
TypedFilter( createCommunitiesGeohashesFilter(),
types = setOf(FeedType.GLOBAL), createPublicChatsTagsFilter(),
filter = createPublicChatsGeohashesFilter(),
JsonFilter( ),
authors = follows, )
kinds = listOf(ClassifiedsEvent.KIND), .ifEmpty { null }
limit = 300, }
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"t" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
"g" to
it
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
},
)
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
authors = follows,
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
if (followChats.isNotEmpty()) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
} else {
null
},
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
JsonFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
)
}
val discoveryFeedChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultDiscoveryFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter(),
),
)
.ifEmpty { null }
}
} }
@@ -34,48 +34,48 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") { object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
private var geohashToWatch: String? = null private var geohashToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? { fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = geohashToWatch ?: return null val hashToLoad = geohashToWatch ?: return null
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
tags = tags =
mapOf( mapOf(
"g" to "g" to
listOf( listOf(
hashToLoad, hashToLoad,
),
),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
),
limit = 200,
), ),
), )
kinds = }
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
),
limit = 200,
),
)
}
val loadGeohashChannel = requestNewChannel() val loadGeohashChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
} }
fun loadHashtag(tag: String?) { fun loadHashtag(tag: String?) {
geohashToWatch = tag geohashToWatch = tag
invalidateFilters() invalidateFilters()
} }
} }
@@ -34,51 +34,51 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? { fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null val hashToLoad = hashtagToWatch ?: return null
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
tags = tags =
mapOf( mapOf(
"t" to "t" to
listOf( listOf(
hashToLoad, hashToLoad,
hashToLoad.lowercase(), hashToLoad.lowercase(),
hashToLoad.uppercase(), hashToLoad.uppercase(),
hashToLoad.capitalize(), hashToLoad.capitalize(),
),
),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
),
limit = 200,
), ),
), )
kinds = }
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
),
limit = 200,
),
)
}
val loadHashtagChannel = requestNewChannel() val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
} }
fun loadHashtag(tag: String?) { fun loadHashtag(tag: String?) {
hashtagToWatch = tag hashtagToWatch = tag
invalidateFilters() invalidateFilters()
} }
} }
@@ -45,190 +45,191 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
object NostrHomeDataSource : NostrDataSource("HomeFeed") { object NostrHomeDataSource : NostrDataSource("HomeFeed") {
lateinit var account: Account lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
var job: Job? = null var job: Job? = null
override fun start() { override fun start() {
job?.cancel() job?.cancel()
job = job =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
// creates cache on main // creates cache on main
withContext(Dispatchers.Main) { account.userProfile().live() } withContext(Dispatchers.Main) { account.userProfile().live() }
account.liveHomeFollowLists.collect { account.liveHomeFollowLists.collect {
if (this@NostrHomeDataSource::account.isInitialized) { if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters() invalidateFilters()
} }
} }
} }
super.start() super.start()
} }
override fun stop() { override fun stop() {
super.stop() super.stop()
job?.cancel() job?.cancel()
} }
fun createFollowAccountsFilter(): TypedFilter { fun createFollowAccountsFilter(): TypedFilter {
val follows = account.liveHomeFollowLists.value?.users val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null } val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter( return TypedFilter(
types = setOf(FeedType.FOLLOWS), types = setOf(FeedType.FOLLOWS),
filter = filter =
JsonFilter( JsonFilter(
kinds = kinds =
listOf( listOf(
TextNoteEvent.KIND, TextNoteEvent.KIND,
RepostEvent.KIND, RepostEvent.KIND,
GenericRepostEvent.KIND, GenericRepostEvent.KIND,
ClassifiedsEvent.KIND, ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
PollNoteEvent.KIND, PollNoteEvent.KIND,
HighlightEvent.KIND, HighlightEvent.KIND,
AudioTrackEvent.KIND, AudioTrackEvent.KIND,
AudioHeaderEvent.KIND, AudioHeaderEvent.KIND,
PinListEvent.KIND, PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND, LiveActivitiesEvent.KIND,
), ),
authors = followSet, authors = followSet,
limit = 400, limit = 400,
since = since =
latestEOSEs.users[account.userProfile()] latestEOSEs.users[account.userProfile()]
?.followList ?.followList
?.get(account.defaultHomeFollowList.value) ?.get(account.defaultHomeFollowList.value)
?.relayList, ?.relayList,
), ),
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
if (communitiesToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
mapOf(
"a" to communitiesToLoad.toList(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
}
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultHomeFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
) )
.ifEmpty { null } }
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
if (communitiesToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
mapOf(
"a" to communitiesToLoad.toList(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
}
val followAccountChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultHomeFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
.ifEmpty { null }
}
} }
@@ -30,50 +30,50 @@ import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSigner
class NostrLnZapPaymentResponseDataSource( class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String, private val fromServiceHex: String,
private val toUserHex: String, private val toUserHex: String,
private val replyingToHex: String, private val replyingToHex: String,
private val authSigner: NostrSigner, private val authSigner: NostrSigner,
) : NostrDataSource("LnZapPaymentResponseFeed") { ) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT) val feedTypes = setOf(FeedType.WALLET_CONNECT)
private fun createWalletConnectServiceWatcher(): TypedFilter { private fun createWalletConnectServiceWatcher(): TypedFilter {
// downloads all the reactions to a given event. // downloads all the reactions to a given event.
return TypedFilter( return TypedFilter(
types = feedTypes, types = feedTypes,
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND), kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex), authors = listOf(fromServiceHex),
tags = tags =
mapOf( mapOf(
"e" to listOf(replyingToHex), "e" to listOf(replyingToHex),
"p" to listOf(toUserHex), "p" to listOf(toUserHex),
), ),
limit = 1, limit = 1,
), ),
) )
} }
val channel = requestNewChannel() val channel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
val wc = createWalletConnectServiceWatcher() val wc = createWalletConnectServiceWatcher()
channel.typedFilters = listOfNotNull(wc).ifEmpty { null } channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
} }
override fun auth( override fun auth(
relay: Relay, relay: Relay,
challenge: String, challenge: String,
) { ) {
super.auth(relay, challenge) super.auth(relay, challenge)
RelayAuthEvent.create(relay.url, challenge, authSigner) { RelayAuthEvent.create(relay.url, challenge, authSigner) {
Client.send( Client.send(
it, it,
relay.url, relay.url,
) )
}
} }
}
} }
@@ -48,120 +48,120 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null private var searchString: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? { private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString val mySearchString = searchString
if (mySearchString.isNullOrBlank()) { if (mySearchString.isNullOrBlank()) {
return null return null
} }
val hexToWatch = val hexToWatch =
try { try {
val isAStraightHex = val isAStraightHex =
if (HexValidator.isHex(mySearchString)) { if (HexValidator.isHex(mySearchString)) {
Hex.decode(mySearchString).toHexKey() Hex.decode(mySearchString).toHexKey()
} else { } else {
null null
} }
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
// downloads all the reactions to a given event. // downloads all the reactions to a given event.
return listOfNotNull( return listOfNotNull(
hexToWatch?.let { hexToWatch?.let {
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
ids = listOfNotNull(hexToWatch), ids = listOfNotNull(hexToWatch),
),
)
},
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
),
)
},
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
),
search = mySearchString,
limit = 100,
),
), ),
) )
},
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
),
)
},
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
)
}
val searchChannel = requestNewChannel()
override fun updateChannelFilters() {
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(searchString: String) {
if (this.searchString != searchString) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
this.searchString = searchString
invalidateFilters()
} }
}
fun clear() { val searchChannel = requestNewChannel()
if (searchString != null) {
println("DataSource: ${this.javaClass.simpleName} Clear") override fun updateChannelFilters() {
searchString = null searchChannel.typedFilters = createAnythingWithIDFilter()
invalidateFilters() }
fun search(searchString: String) {
if (this.searchString != searchString) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
this.searchString = searchString
invalidateFilters()
}
}
fun clear() {
if (searchString != null) {
println("DataSource: ${this.javaClass.simpleName} Clear")
searchString = null
invalidateFilters()
}
} }
}
} }
@@ -31,95 +31,95 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<Channel>() private var channelsToWatch = setOf<Channel>()
private fun createMetadataChangeFilter(): TypedFilter? { private fun createMetadataChangeFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
if (reactionsToWatch.isEmpty()) { if (reactionsToWatch.isEmpty()) {
return null return null
} }
// downloads all the reactions to a given event. // downloads all the reactions to a given event.
return TypedFilter( return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS), types = setOf(FeedType.PUBLIC_CHATS),
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(ChannelMetadataEvent.KIND), kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch), tags = mapOf("e" to reactionsToWatch),
), ),
)
}
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad =
channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad =
channelsToWatch.filterIsInstance<LiveActivitiesChannel>().filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
),
) )
}
} }
}
val singleChannelChannel = requestNewChannel() fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad =
channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel }
override fun updateChannelFilters() { val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = if (interestedEvents.isEmpty()) {
((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } return null
} }
fun add(eventId: Channel) { // downloads linked events to this event.
if (eventId !in channelsToWatch) { return TypedFilter(
channelsToWatch = channelsToWatch.plus(eventId) types = COMMON_FEED_TYPES,
invalidateFilters() filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
)
} }
}
fun remove(eventId: Channel) { fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
if (eventId in channelsToWatch) { val directEventsToLoad =
channelsToWatch = channelsToWatch.minus(eventId) channelsToWatch.filterIsInstance<LiveActivitiesChannel>().filter { it.info == null }
invalidateFilters()
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
),
)
}
}
}
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters =
((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null }
}
fun add(eventId: Channel) {
if (eventId !in channelsToWatch) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Channel) {
if (eventId in channelsToWatch) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
} }
}
} }
@@ -38,242 +38,245 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>() private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>() private var addressesToWatch = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? { private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch = val addressesToWatch =
(eventsToWatch.filter { it.address() != null } + (
addressesToWatch.filter { it.address() != null }) eventsToWatch.filter { it.address() != null } +
.toSet() addressesToWatch.filter { it.address() != null }
)
.toSet()
if (addressesToWatch.isEmpty()) { if (addressesToWatch.isEmpty()) {
return null return null
}
return groupByEOSEPresence(addressesToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
)
}
}
private fun createAddressFilter(): List<TypedFilter>? {
val addressesToWatch = addressesToWatch.filter { it.event == null }
if (addressesToWatch.isEmpty()) {
return null
}
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
} }
}
}
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? { return groupByEOSEPresence(addressesToWatch).map {
if (eventsToWatch.isEmpty()) { TypedFilter(
return null types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
)
}
} }
return groupByEOSEPresence(eventsToWatch).map { private fun createAddressFilter(): List<TypedFilter>? {
TypedFilter( val addressesToWatch = addressesToWatch.filter { it.event == null }
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
)
}
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? { if (addressesToWatch.isEmpty()) {
val directEventsToLoad = eventsToWatch.filter { it.event == null } return null
}
val threadingEventsToLoad = return addressesToWatch.mapNotNull {
eventsToWatch it.address()?.let { aTag ->
.mapNotNull { it.replyTo } if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
.flatten() TypedFilter(
.filter { it !is AddressableNote && it.event == null } types = COMMON_FEED_TYPES,
filter =
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet() JsonFilter(
kinds = listOf(aTag.kind),
if (interestedEvents.isEmpty()) { authors = listOf(aTag.pubKeyHex),
return null limit = 5,
),
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 5,
),
)
}
}
}
} }
// downloads linked events to this event. private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
return listOf( if (eventsToWatch.isEmpty()) {
TypedFilter( return null
types = COMMON_FEED_TYPES, }
filter =
JsonFilter(
ids = interestedEvents.toList(),
),
),
)
}
val singleEventChannel = requestNewChannel { time, relayUrl -> return groupByEOSEPresence(eventsToWatch).map {
// Ignores EOSE if it is in the middle of a filter change. TypedFilter(
if (changingFilters.get()) return@requestNewChannel types = COMMON_FEED_TYPES,
filter =
checkNotInMainThread() JsonFilter(
kinds =
eventsToWatch.forEach { listOf(
val eose = it.lastReactionsDownloadTime[relayUrl] TextNoteEvent.KIND,
if (eose == null) { ReactionEvent.KIND,
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) RepostEvent.KIND,
} else { GenericRepostEvent.KIND,
eose.time = time ReportEvent.KIND,
} LnZapEvent.KIND,
PollNoteEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
)
}
} }
addressesToWatch.forEach { fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val eose = it.lastReactionsDownloadTime[relayUrl] val directEventsToLoad = eventsToWatch.filter { it.event == null }
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) val threadingEventsToLoad =
} else { eventsToWatch
eose.time = time .mapNotNull { it.replyTo }
} .flatten()
.filter { it !is AddressableNote && it.event == null }
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
ids = interestedEvents.toList(),
),
),
)
} }
// Many relays operate with limits in the amount of filters. val singleEventChannel =
// As information comes, the filters will be rotated to get more data. requestNewChannel { time, relayUrl ->
invalidateFilters() // Ignores EOSE if it is in the middle of a filter change.
} if (changingFilters.get()) return@requestNewChannel
override fun updateChannelFilters() { checkNotInMainThread()
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
singleEventChannel.typedFilters = eventsToWatch.forEach {
listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } val eose = it.lastReactionsDownloadTime[relayUrl]
} if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
fun add(eventId: Note) { addressesToWatch.forEach {
if (!eventsToWatch.contains(eventId)) { val eose = it.lastReactionsDownloadTime[relayUrl]
eventsToWatch = eventsToWatch.plus(eventId) if (eose == null) {
invalidateFilters() it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
singleEventChannel.typedFilters =
listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
} }
}
fun remove(eventId: Note) { fun add(eventId: Note) {
if (eventsToWatch.contains(eventId)) { if (!eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.minus(eventId) eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters() invalidateFilters()
}
} }
}
fun addAddress(addressableNote: Note) { fun remove(eventId: Note) {
if (!addressesToWatch.contains(addressableNote)) { if (eventsToWatch.contains(eventId)) {
addressesToWatch = addressesToWatch.plus(addressableNote) eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters() invalidateFilters()
}
} }
}
fun removeAddress(addressableNote: Note) { fun addAddress(addressableNote: Note) {
if (addressesToWatch.contains(addressableNote)) { if (!addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.minus(addressableNote) addressesToWatch = addressesToWatch.plus(addressableNote)
invalidateFilters() invalidateFilters()
}
}
fun removeAddress(addressableNote: Note) {
if (addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.minus(addressableNote)
invalidateFilters()
}
} }
}
} }
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> { fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> {
return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values
} }
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> { fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> {
return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values
} }
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> { fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>() val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach { notes.forEach {
it.lastReactionsDownloadTime.forEach { it.lastReactionsDownloadTime.forEach {
val minEose = minLatestEOSEs[it.key] val minEose = minLatestEOSEs[it.key]
if (minEose == null) { if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time)) minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) { } else if (it.value.time < minEose.time) {
minEose.time = it.value.time minEose.time = it.value.time
} }
}
} }
}
return minLatestEOSEs return minLatestEOSEs
} }
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> { fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>() val minLatestEOSEs = mutableMapOf<String, EOSETime>()
users.forEach { users.forEach {
it.latestEOSEs.forEach { it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key] val minEose = minLatestEOSEs[it.key]
if (minEose == null) { if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time)) minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) { } else if (it.value.time < minEose.time) {
minEose.time = it.value.time minEose.time = it.value.time
} }
}
} }
}
return minLatestEOSEs return minLatestEOSEs
} }
@@ -30,100 +30,101 @@ import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>() var usersToWatch = setOf<User>()
fun createUserMetadataFilter(): List<TypedFilter>? { fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null if (usersToWatch.isEmpty()) return null
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex } val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
if (firstTimers.isEmpty()) return null if (firstTimers.isEmpty()) return null
return listOf( return listOf(
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
authors = firstTimers, authors = firstTimers,
), ),
), ),
)
}
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
if (secondTimers.isEmpty()) return null
return groupByEOSEPresence(secondTimers)
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
) )
} }
.flatten()
}
val userChannel = requestNewChannel { time, relayUrl -> fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
checkNotInMainThread() if (usersToWatch.isEmpty()) return null
usersToWatch.forEach { val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
if (it.info?.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl] if (secondTimers.isEmpty()) return null
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) return groupByEOSEPresence(secondTimers)
} else { .map { group ->
eose.time = time val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
),
),
)
}
.flatten()
}
val userChannel =
requestNewChannel { time, relayUrl ->
checkNotInMainThread()
usersToWatch.forEach {
if (it.info?.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
} }
}
override fun updateChannelFilters() {
checkNotInMainThread()
userChannel.typedFilters =
listOfNotNull(
createUserMetadataFilter(),
createUserMetadataStatusReportFilter(),
)
.flatten()
.ifEmpty { null }
} }
}
override fun updateChannelFilters() { fun add(user: User) {
checkNotInMainThread() if (!usersToWatch.contains(user)) {
usersToWatch = usersToWatch.plus(user)
userChannel.typedFilters = invalidateFilters()
listOfNotNull( }
createUserMetadataFilter(),
createUserMetadataStatusReportFilter(),
)
.flatten()
.ifEmpty { null }
}
fun add(user: User) {
if (!usersToWatch.contains(user)) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
} }
}
fun remove(user: User) { fun remove(user: User) {
if (usersToWatch.contains(user)) { if (usersToWatch.contains(user)) {
usersToWatch = usersToWatch.minus(user) usersToWatch = usersToWatch.minus(user)
invalidateFilters() invalidateFilters()
}
} }
}
} }
@@ -26,47 +26,48 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val threadToLoad = eventToWatch ?: return null val threadToLoad = eventToWatch ?: return null
val eventsToLoad = val eventsToLoad =
ThreadAssembler() ThreadAssembler()
.findThreadFor(threadToLoad) .findThreadFor(threadToLoad)
.filter { it.event == null } .filter { it.event == null }
.map { it.idHex } .map { it.idHex }
.toSet() .toSet()
.ifEmpty { null } .ifEmpty { null }
?: return null ?: return null
if (eventsToLoad.isEmpty()) return null if (eventsToLoad.isEmpty()) return null
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
ids = eventsToLoad.toList(), ids = eventsToLoad.toList(),
), ),
) )
} }
val loadEventsChannel = requestNewChannel { _, _ -> val loadEventsChannel =
// Many relays operate with limits in the amount of filters. requestNewChannel { _, _ ->
// As information comes, the filters will be rotated to get more data. // Many relays operate with limits in the amount of filters.
invalidateFilters() // As information comes, the filters will be rotated to get more data.
} invalidateFilters()
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = override fun updateChannelFilters() {
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } loadEventsChannel.typedFilters =
} listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
fun loadThread(noteId: String?) {
if (eventToWatch != noteId) { fun loadThread(noteId: String?) {
eventToWatch = noteId if (eventToWatch != noteId) {
eventToWatch = noteId
invalidateFilters()
invalidateFilters()
}
} }
}
} }
@@ -43,140 +43,140 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null var user: User? = null
fun loadUserProfile(user: User?) { fun loadUserProfile(user: User?) {
this.user = user this.user = user
}
fun createUserInfoFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
} }
fun createUserPostsFilter() = fun createUserInfoFilter() =
user?.let { user?.let {
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( JsonFilter(
kinds = kinds = listOf(MetadataEvent.KIND),
listOf( authors = listOf(it.pubkeyHex),
TextNoteEvent.KIND, limit = 1,
GenericRepostEvent.KIND, ),
RepostEvent.KIND, )
LongTextNoteEvent.KIND, }
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND, fun createUserPostsFilter() =
PinListEvent.KIND, user?.let {
PollNoteEvent.KIND, TypedFilter(
HighlightEvent.KIND, types = COMMON_FEED_TYPES,
), filter =
authors = listOf(it.pubkeyHex), JsonFilter(
limit = 200, kinds =
), listOf(
) TextNoteEvent.KIND,
GenericRepostEvent.KIND,
RepostEvent.KIND,
LongTextNoteEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 200,
),
)
}
fun createUserReceivedZapsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
)
}
fun createFollowFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
}
fun createFollowersFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
)
}
fun createAcceptedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
}
fun createBookmarksFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 100,
),
)
}
fun createReceivedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
),
)
}
val userInfoChannel = requestNewChannel()
override fun updateChannelFilters() {
userInfoChannel.typedFilters =
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter(),
createBookmarksFilter(),
)
.ifEmpty { null }
} }
fun createUserReceivedZapsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
)
}
fun createFollowFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
}
fun createFollowersFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
)
}
fun createAcceptedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
),
)
}
fun createBookmarksFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 100,
),
)
}
fun createReceivedAwardsFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
),
)
}
val userInfoChannel = requestNewChannel()
override fun updateChannelFilters() {
userInfoChannel.typedFilters =
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter(),
createBookmarksFilter(),
)
.ifEmpty { null }
}
} }
@@ -33,120 +33,121 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
object NostrVideoDataSource : NostrDataSource("VideoFeed") { object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
var job: Job? = null var job: Job? = null
override fun start() { override fun start() {
job?.cancel() job?.cancel()
job = job =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
account.liveStoriesFollowLists.collect { account.liveStoriesFollowLists.collect {
if (this@NostrVideoDataSource::account.isInitialized) { if (this@NostrVideoDataSource::account.isInitialized) {
invalidateFilters() invalidateFilters()
} }
} }
} }
super.start() super.start()
} }
override fun stop() { override fun stop() {
super.stop() super.stop()
job?.cancel() job?.cancel()
} }
fun createContextualFilter(): TypedFilter { fun createContextualFilter(): TypedFilter {
val follows = account.liveStoriesFollowLists.value?.users?.toList() val follows = account.liveStoriesFollowLists.value?.users?.toList()
return TypedFilter( return TypedFilter(
types = setOf(FeedType.GLOBAL), types = setOf(FeedType.GLOBAL),
filter = filter =
JsonFilter( JsonFilter(
authors = follows, authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200, limit = 200,
since = since =
latestEOSEs.users[account.userProfile()] latestEOSEs.users[account.userProfile()]
?.followList ?.followList
?.get(account.defaultStoriesFollowList.value) ?.get(account.defaultStoriesFollowList.value)
?.relayList, ?.relayList,
), ),
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
)
}
val videoFeedChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultStoriesFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
videoFeedChannel.typedFilters =
listOfNotNull(
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
) )
.ifEmpty { null } }
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultStoriesFollowList.value)
?.relayList,
),
)
}
val videoFeedChannel =
requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(
account.userProfile(),
account.defaultStoriesFollowList.value,
relayUrl,
time,
)
}
override fun updateChannelFilters() {
videoFeedChannel.typedFilters =
listOfNotNull(
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
.ifEmpty { null }
}
} }
@@ -29,46 +29,46 @@ import okhttp3.Request
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean) @Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker { object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100) val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
val fiveMinutes = 1000 * 60 * 5 val fiveMinutes = 1000 * 60 * 5
fun isOnlineCached(url: String?): Boolean { fun isOnlineCached(url: String?): Boolean {
if (url.isNullOrBlank()) return false if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online return checkOnlineCache.get(url).online
} }
return false return false
} }
fun isOnline(url: String?): Boolean { fun isOnline(url: String?): Boolean {
checkNotInMainThread() checkNotInMainThread()
if (url.isNullOrBlank()) return false if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online return checkOnlineCache.get(url).online
} }
Log.d("OnlineChecker", "isOnline $url") Log.d("OnlineChecker", "isOnline $url")
return try { return try {
val request = val request =
Request.Builder() Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url) .url(url)
.get() .get()
.build() .build()
val result = val result =
HttpClient.getHttpClient().newCall(request).execute().use { HttpClient.getHttpClient().newCall(request).execute().use {
checkNotInMainThread() checkNotInMainThread()
it.isSuccessful it.isSuccessful
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
} }
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
} }
}
} }
@@ -23,20 +23,20 @@ package com.vitorpamplona.amethyst.service
import android.content.Context import android.content.Context
object PackageUtils { object PackageUtils {
private fun isPackageInstalled( private fun isPackageInstalled(
context: Context, context: Context,
target: String, target: String,
): Boolean { ): Boolean {
return context.packageManager.getInstalledApplications(0).find { info -> return context.packageManager.getInstalledApplications(0).find { info ->
info.packageName == target info.packageName == target
} != null } != null
} }
fun isOrbotInstalled(context: Context): Boolean { fun isOrbotInstalled(context: Context): Boolean {
return isPackageInstalled(context, "org.torproject.android") return isPackageInstalled(context, "org.torproject.android")
} }
fun isAmberInstalled(context: Context): Boolean { fun isAmberInstalled(context: Context): Boolean {
return isPackageInstalled(context, "com.greenart7c3.nostrsigner") return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
} }
} }
@@ -32,230 +32,229 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlin.math.round
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.round
class ZapPaymentHandler(val account: Account) { class ZapPaymentHandler(val account: Account) {
@Immutable @Immutable
data class Payable( data class Payable(
val info: ZapSplitSetup, val info: ZapSplitSetup,
val user: User?, val user: User?,
val amountMilliSats: Long, val amountMilliSats: Long,
val invoice: String, val invoice: String,
) )
suspend fun zap( suspend fun zap(
note: Note, note: Note,
amountMilliSats: Long, amountMilliSats: Long,
pollOption: Int?, pollOption: Int?,
message: String, message: String,
context: Context, context: Context,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit, onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
) = ) = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { val zapSplitSetup = note.event?.zapSplitSetup()
val zapSplitSetup = note.event?.zapSplitSetup()
val noteEvent = note.event val noteEvent = note.event
val zapsToSend = val zapsToSend =
if (!zapSplitSetup.isNullOrEmpty()) { if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup zapSplitSetup
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) } noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
} else { } else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) { if (lud16.isNullOrBlank()) {
onError( onError(
context.getString(R.string.missing_lud16), context.getString(R.string.missing_lud16),
context.getString( context.getString(
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
), ),
) )
return@withContext return@withContext
} }
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
} }
val totalWeight = zapsToSend.sumOf { it.weight } val totalWeight = zapsToSend.sumOf { it.weight }
val invoicesToPayOnIntent = mutableListOf<Payable>() val invoicesToPayOnIntent = mutableListOf<Payable>()
zapsToSend.forEachIndexed { index, value -> zapsToSend.forEachIndexed { index, value ->
val outerProgressMin = index / zapsToSend.size.toFloat() val outerProgressMin = index / zapsToSend.size.toFloat()
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
if (value.isLnAddress) { if (value.isLnAddress) {
innerZap( innerZap(
lud16 = value.lnAddressOrPubKeyHex, lud16 = value.lnAddressOrPubKeyHex,
note = note, note = note,
amount = zapValue, amount = zapValue,
pollOption = pollOption, pollOption = pollOption,
message = message, message = message,
context = context, context = context,
onError = onError, onError = onError,
onProgress = { onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
}, },
zapType = zapType, zapType = zapType,
onPayInvoiceThroughIntent = { onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add( invoicesToPayOnIntent.add(
Payable( Payable(
info = value, info = value,
user = null, user = null,
amountMilliSats = zapValue, amountMilliSats = zapValue,
invoice = it, invoice = it,
), ),
) )
}, },
)
} else {
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
val lud16 = user?.info?.lnAddress()
if (lud16 != null) {
innerZap(
lud16 = lud16,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
overrideUser = user,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it,
),
) )
}, } else {
) val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
} else { val lud16 = user?.info?.lnAddress()
onError(
context.getString(
R.string.missing_lud16,
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
),
)
}
}
}
if (invoicesToPayOnIntent.isNotEmpty()) { if (lud16 != null) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) innerZap(
onProgress(1f) lud16 = lud16,
} else { note = note,
launch(Dispatchers.IO) { amount = zapValue,
// Awaits for the event to come back to LocalCache. pollOption = pollOption,
var count = 0 message = message,
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) { context = context,
count++ onError = onError,
Thread.sleep(5000) onProgress = {
} onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
if (invoicesToPayOnIntent.isNotEmpty()) { },
zapType = zapType,
overrideUser = user,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it,
),
)
},
)
} else {
onError(
context.getString(
R.string.missing_lud16,
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
),
)
}
}
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f) onProgress(1f)
} else { } else {
onProgress(1f) launch(Dispatchers.IO) {
} // Awaits for the event to come back to LocalCache.
} var count = 0
} while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
} count++
Thread.sleep(5000)
private fun prepareZapRequestIfNeeded( }
note: Note, if (invoicesToPayOnIntent.isNotEmpty()) {
pollOption: Int?, onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
onReady(zapRequest.toJson())
}
} else {
onReady(null)
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
) {
onProgress(0.05f)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
onProgress(0.10f)
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
onProgress(1f) onProgress(1f)
} } else {
}, onProgress(1f)
) }
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
} }
}, }
onError = onError, }
onProgress = onProgress,
context = context, private fun prepareZapRequestIfNeeded(
) note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
onReady(zapRequest.toJson())
}
} else {
onReady(null)
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
) {
onProgress(0.05f)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
onProgress(0.10f)
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
onProgress(1f)
}
},
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress,
context = context,
)
}
} }
}
} }
@@ -29,263 +29,263 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06 import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl import com.vitorpamplona.quartz.encoders.toLnUrl
import okhttp3.Request
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.net.URLEncoder import java.net.URLEncoder
import okhttp3.Request
class LightningAddressResolver() { class LightningAddressResolver() {
val client = HttpClient.getHttpClient() val client = HttpClient.getHttpClient()
fun assembleUrl(lnaddress: String): String? { fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@") val parts = lnaddress.split("@")
if (parts.size == 2) { if (parts.size == 2) {
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
if (lnaddress.lowercase().startsWith("lnurl")) {
return Lud06().toLnUrlp(lnaddress)
}
return null
}
private fun fetchLightningAddressJson(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
lnaddress,
),
)
return
}
try {
val request: Request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress,
it.code.toString(),
),
)
} }
}
} catch (e: Exception) {
e.printStackTrace()
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress,
),
)
}
}
fun fetchLightningInvoice( if (lnaddress.lowercase().startsWith("lnurl")) {
lnCallback: String, return Lud06().toLnUrlp(lnaddress)
milliSats: Long, }
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
val encodedMessage = URLEncoder.encode(message, "utf-8") return null
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
} }
val request: Request = private fun fetchLightningAddressJson(
Request.Builder() lnaddress: String,
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") onSuccess: (String) -> Unit,
.url(url) onError: (String, String) -> Unit,
.build() context: Context,
) {
checkNotInMainThread()
client.newCall(request).execute().use { val url = assembleUrl(lnaddress)
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.could_not_fetch_invoice_from, lnCallback),
)
}
}
}
fun lnAddressToLnUrl( if (url == null) {
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
fetchLightningAddressJson(
lnaddress,
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
onError = onError,
context = context,
)
}
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(
lnaddress,
onSuccess = { lnAddressJson ->
onProgress(0.4f)
val lnurlp =
try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
onError( onError(
context.getString(R.string.error_unable_to_fetch_invoice), context.getString(R.string.error_unable_to_fetch_invoice),
context.getString( context.getString(
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
), lnaddress,
),
) )
null return
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
),
)
} }
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false try {
val request: Request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
callback?.let { cb -> client.newCall(request).execute().use {
fetchLightningInvoice( if (it.isSuccessful) {
cb, onSuccess(it.body.string())
milliSats, } else {
message, onError(
if (allowsNostr) nostrRequest else null, context.getString(R.string.error_unable_to_fetch_invoice),
onSuccess = { context.getString(
onProgress(0.6f) R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress,
it.code.toString(),
),
)
}
}
} catch (e: Exception) {
e.printStackTrace()
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress,
),
)
}
}
val lnInvoice = fun fetchLightningInvoice(
try { lnCallback: String,
mapper.readTree(it) milliSats: Long,
} catch (t: Throwable) { message: String,
onError( nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
checkNotInMainThread()
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice), context.getString(R.string.error_unable_to_fetch_invoice),
context.getString( context.getString(R.string.could_not_fetch_invoice_from, lnCallback),
R.string )
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup, }
), }
) }
null
fun lnAddressToLnUrl(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
fetchLightningAddressJson(
lnaddress,
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
onError = onError,
context = context,
)
}
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(
lnaddress,
onSuccess = { lnAddressJson ->
onProgress(0.4f)
val lnurlp =
try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
),
)
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
),
)
} }
lnInvoice val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
?.get("pr")
?.asText() callback?.let { cb ->
?.ifBlank { null } fetchLightningInvoice(
?.let { pr -> cb,
// Forces LN Invoice amount to be the requested amount. milliSats,
val expectedAmountInSats = message,
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() if (allowsNostr) nostrRequest else null,
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) onSuccess = {
if (invoiceAmount.toLong() == expectedAmountInSats) { onProgress(0.6f)
onProgress(0.7f)
onSuccess(pr) val lnInvoice =
} else { try {
onProgress(0.0f) mapper.readTree(it)
onError( } catch (t: Throwable) {
context.getString(R.string.error_unable_to_fetch_invoice), onError(
context.getString( context.getString(R.string.error_unable_to_fetch_invoice),
R.string.incorrect_invoice_amount_sats_from_it_should_have_been, context.getString(
invoiceAmount.toLong().toString(), R.string
lnaddress, .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
expectedAmountInSats.toString(), ),
), )
null
}
lnInvoice
?.get("pr")
?.asText()
?.ifBlank { null }
?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val expectedAmountInSats =
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f)
onSuccess(pr)
} else {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
expectedAmountInSats.toString(),
),
)
}
}
?: lnInvoice
?.get("reason")
?.asText()
?.ifBlank { null }
?.let { reason ->
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
reason,
),
)
}
?: run {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
),
)
}
},
onError = onError,
context,
) )
}
}
?: lnInvoice
?.get("reason")
?.asText()
?.ifBlank { null }
?.let { reason ->
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
reason,
),
)
}
?: run {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
),
)
} }
}, },
onError = onError, onError = onError,
context, context,
) )
} }
},
onError = onError,
context,
)
}
} }
@@ -25,12 +25,14 @@ import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
import com.vitorpamplona.quartz.events.LnZapEventInterface import com.vitorpamplona.quartz.events.LnZapEventInterface
object UserZaps { object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> { fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
if (zaps == null) return emptyList() if (zaps == null) return emptyList()
return (zaps return (
.mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } } zaps
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() } .mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } }
.reversed()) .sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
} .reversed()
)
}
} }
@@ -40,203 +40,207 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import java.math.BigDecimal
class EventNotificationConsumer(private val applicationContext: Context) { class EventNotificationConsumer(private val applicationContext: Context) {
suspend fun consume(event: GiftWrapEvent) { suspend fun consume(event: GiftWrapEvent) {
if (!LocalCache.justVerify(event)) return if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return if (!notificationManager().areNotificationsEnabled()) return
// PushNotification Wraps don't include a receiver. // PushNotification Wraps don't include a receiver.
// Test with all logged in accounts // Test with all logged in accounts
LocalPreferences.allSavedAccounts().forEach { LocalPreferences.allSavedAccounts().forEach {
if (it.hasPrivKey || it.loggedInWithExternalSigner) { if (it.hasPrivKey || it.loggedInWithExternalSigner) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
consumeIfMatchesAccount(event, acc) consumeIfMatchesAccount(event, acc)
} }
}
}
}
private suspend fun consumeIfMatchesAccount(
pushWrappedEvent: GiftWrapEvent,
account: Account,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
private fun unwrapAndConsume(
event: Event,
account: Account,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
}
else -> {
LocalCache.justConsume(event, null)
onReady(event)
}
}
}
private fun notify(
event: ChatMessageEvent,
acc: Account,
) {
if (
event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
val chatNote = LocalCache.notes[event.id] ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet()
val isKnownRoom =
(acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)) && !acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager()
.sendDMNotification(
event.id,
content,
user,
userPicture,
noteUri,
applicationContext,
)
}
}
}
private fun notify(
event: PrivateDmEvent,
acc: Account,
) {
val note = LocalCache.notes[event.id] ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val knownChatrooms =
acc
.userProfile()
.privateChatrooms
.keys
.filter {
(acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)) && !acc.isAllHidden(it.users)
}
.toSet()
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
acc.decryptContent(note) { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager()
.sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
}
private fun notify(
event: LnZapEvent,
acc: Account,
) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped =
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
acc.decryptContent(noteZapped) {
val zappedContent = it.split("\n").get(0)
val user = senderInfo.first.toBestDisplayName()
var title =
applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
applicationContext.getString(
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent?.let {
content +=
" " +
applicationContext.getString(
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
} }
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager()
.sendZapNotification(
event.id,
content,
title,
userPicture,
noteUri,
applicationContext,
)
}
} }
}
} }
}
fun notificationManager(): NotificationManager { private suspend fun consumeIfMatchesAccount(
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) pushWrappedEvent: GiftWrapEvent,
as NotificationManager account: Account,
} ) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
private fun unwrapAndConsume(
event: Event,
account: Account,
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
}
else -> {
LocalCache.justConsume(event, null)
onReady(event)
}
}
}
private fun notify(
event: ChatMessageEvent,
acc: Account,
) {
if (
event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
val chatNote = LocalCache.notes[event.id] ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet()
val isKnownRoom =
(
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager()
.sendDMNotification(
event.id,
content,
user,
userPicture,
noteUri,
applicationContext,
)
}
}
}
private fun notify(
event: PrivateDmEvent,
acc: Account,
) {
val note = LocalCache.notes[event.id] ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val knownChatrooms =
acc
.userProfile()
.privateChatrooms
.keys
.filter {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}
.toSet()
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
acc.decryptContent(note) { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager()
.sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
}
private fun notify(
event: LnZapEvent,
acc: Account,
) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped =
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
acc.decryptContent(noteZapped) {
val zappedContent = it.split("\n").get(0)
val user = senderInfo.first.toBestDisplayName()
var title =
applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
applicationContext.getString(
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent?.let {
content +=
" " +
applicationContext.getString(
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager()
.sendZapNotification(
event.id,
content,
title,
userPicture,
noteUri,
applicationContext,
)
}
}
}
}
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
}
} }
@@ -36,213 +36,213 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils { object NotificationUtils {
private var dmChannel: NotificationChannel? = null private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel { fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!! if (dmChannel != null) return dmChannel!!
dmChannel = dmChannel =
NotificationChannel( NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id), applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name), applicationContext.getString(R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT, NotificationManager.IMPORTANCE_DEFAULT,
) )
.apply { .apply {
description = description =
applicationContext.getString(R.string.app_notification_dms_channel_description) applicationContext.getString(R.string.app_notification_dms_channel_description)
} }
// Register the channel with the system // Register the channel with the system
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(dmChannel!!) notificationManager.createNotificationChannel(dmChannel!!)
return dmChannel!! return dmChannel!!
}
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
if (zapChannel != null) return zapChannel!!
zapChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(zapChannel!!)
return zapChannel!!
}
fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
pictureUrl,
uri,
channelId,
ZAP_GROUP_KEY,
applicationContext,
)
}
fun NotificationManager.sendDMNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
pictureUrl,
uri,
channelId,
DM_GROUP_KEY,
applicationContext,
)
}
fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
} else {
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = null,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
}
}
private fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
picture: BitmapDrawable?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
) {
val notId = id.hashCode()
// dont notify twice
val notifications: Array<StatusBarNotification> = getActiveNotifications()
for (notification in notifications) {
if (notification.id == notId) {
return
}
} }
val contentIntent = fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) } if (zapChannel != null) return zapChannel!!
val contentPendingIntent = zapChannel =
PendingIntent.getActivity( NotificationChannel(
applicationContext, applicationContext.getString(R.string.app_notification_zaps_channel_id),
notId, applicationContext.getString(R.string.app_notification_zaps_channel_name),
contentIntent, NotificationManager.IMPORTANCE_DEFAULT,
PendingIntent.FLAG_MUTABLE, )
) .apply {
description =
applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
// Build the notification // Register the channel with the system
val builderPublic = val notificationManager: NotificationManager =
NotificationCompat.Builder( applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
applicationContext,
channelId, notificationManager.createNotificationChannel(zapChannel!!)
return zapChannel!!
}
fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
pictureUrl,
uri,
channelId,
ZAP_GROUP_KEY,
applicationContext,
) )
.setSmallIcon(R.drawable.amethyst) }
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
// activate this
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
// Build the notification fun NotificationManager.sendDMNotification(
val builder = id: String,
NotificationCompat.Builder( messageBody: String,
applicationContext, messageTitle: String,
channelId, pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
pictureUrl,
uri,
channelId,
DM_GROUP_KEY,
applicationContext,
) )
.setSmallIcon(R.drawable.amethyst) }
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
// activate this
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
notify(notId, builder.build()) fun NotificationManager.sendNotification(
} id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
/** Cancels all notifications. */ val imageLoader = ImageLoader(applicationContext)
fun NotificationManager.cancelNotifications() { val imageResult = imageLoader.executeBlocking(request)
cancelAll() sendNotification(
} id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
} else {
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = null,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
}
}
private fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
picture: BitmapDrawable?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
) {
val notId = id.hashCode()
// dont notify twice
val notifications: Array<StatusBarNotification> = getActiveNotifications()
for (notification in notifications) {
if (notification.id == notId) {
return
}
}
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) }
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_MUTABLE,
)
// Build the notification
val builderPublic =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
// activate this
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
// Build the notification
val builder =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
// activate this
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
notify(notId, builder.build())
}
/** Cancels all notifications. */
fun NotificationManager.cancelNotifications() {
cancelAll()
}
} }
@@ -34,96 +34,96 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
class RegisterAccounts( class RegisterAccounts(
private val accounts: List<AccountInfo>, private val accounts: List<AccountInfo>,
) { ) {
private fun recursiveAuthCreation( private fun recursiveAuthCreation(
notificationToken: String, notificationToken: String,
remainingTos: List<Pair<Account, String>>, remainingTos: List<Pair<Account, String>>,
output: MutableList<RelayAuthEvent>, output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit, onReady: (List<RelayAuthEvent>) -> Unit,
) { ) {
if (remainingTos.isEmpty()) { if (remainingTos.isEmpty()) {
onReady(output) onReady(output)
return return
}
val next = remainingTos.first()
next.first.createAuthEvent(next.second, notificationToken) {
output.add(it)
recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady)
}
}
// creates proof that it controls all accounts
private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
val readyToSend =
accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val readRelays =
acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
Pair(acc, relayToUse)
} else {
null
}
} else {
null
} }
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>() val next = remainingTos.first()
recursiveAuthCreation(
notificationToken,
readyToSend,
listOfAuthEvents,
onReady,
)
}
fun postRegistrationEvent(events: List<RelayAuthEvent>) { next.first.createAuthEvent(next.second, notificationToken) {
try { output.add(it)
val jsonObject = recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady)
"""{ }
}
// creates proof that it controls all accounts
private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
val readyToSend =
accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val readRelays =
acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
Pair(acc, relayToUse)
} else {
null
}
} else {
null
}
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
recursiveAuthCreation(
notificationToken,
readyToSend,
listOfAuthEvents,
onReady,
)
}
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject =
"""{
"events": [ ${events.joinToString(", ") { it.toJson() }} ] "events": [ ${events.joinToString(", ") { it.toJson() }} ]
} }
""" """
val mediaType = "application/json; charset=utf-8".toMediaType() val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType) val body = jsonObject.toRequestBody(mediaType)
val request = val request =
Request.Builder() Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register") .url("https://push.amethyst.social/register")
.post(body) .post(body)
.build() .build()
val client = HttpClient.getHttpClient() val client = HttpClient.getHttpClient()
val isSucess = client.newCall(request).execute().use { it.isSuccessful } val isSucess = client.newCall(request).execute().use { it.isSuccessful }
} catch (e: java.lang.Exception) { } catch (e: java.lang.Exception) {
val tag = val tag =
if (BuildConfig.FLAVOR == "play") { if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService" "FirebaseMsgService"
} else { } else {
"UnifiedPushService" "UnifiedPushService"
}
Log.e(tag, "Unable to register with push server", e)
} }
Log.e(tag, "Unable to register with push server", e)
} }
}
suspend fun go(notificationToken: String) = suspend fun go(notificationToken: String) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
PushNotificationUtils.hasInit = true PushNotificationUtils.hasInit = true
} }
} }
@@ -32,138 +32,138 @@ import androidx.media3.common.Player.STATE_READY
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import kotlin.math.abs
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
class MultiPlayerPlaybackManager( class MultiPlayerPlaybackManager(
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null, private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
private val cachedPositions: VideoViewedPositionCache, private val cachedPositions: VideoViewedPositionCache,
) { ) {
// protects from LruCache killing playing sessions // protects from LruCache killing playing sessions
private val playingMap = mutableMapOf<String, MediaSession>() private val playingMap = mutableMapOf<String, MediaSession>()
private val cache = private val cache =
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
override fun entryRemoved( override fun entryRemoved(
evicted: Boolean, evicted: Boolean,
key: String?, key: String?,
oldValue: MediaSession?, oldValue: MediaSession?,
newValue: MediaSession?, newValue: MediaSession?,
) { ) {
super.entryRemoved(evicted, key, oldValue, newValue) super.entryRemoved(evicted, key, oldValue, newValue)
if (!playingMap.contains(key)) { if (!playingMap.contains(key)) {
oldValue?.let { oldValue?.let {
it.player.release() it.player.release()
it.release() it.release()
} }
}
}
}
private fun getCallbackIntent(
callbackUri: String,
applicationContext: Context,
): PendingIntent {
return PendingIntent.getActivity(
applicationContext,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun getMediaSession(
id: String,
uri: String,
callbackUri: String?,
context: Context,
applicationContext: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
if (existingSession != null) return existingSession
val player =
ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) }
build()
}
player.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
}
val mediaSession =
MediaSession.Builder(context, player).run {
callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) }
setId(id)
build()
}
player.addListener(
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
} }
}
} }
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
}
} }
},
)
cache.put(id, mediaSession) private fun getCallbackIntent(
callbackUri: String,
return mediaSession applicationContext: Context,
} ): PendingIntent {
return PendingIntent.getActivity(
@OptIn(DelicateCoroutinesApi::class) applicationContext,
fun releaseAppPlayers() { 0,
GlobalScope.launch(Dispatchers.Main) { Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
cache.evictAll() PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
playingMap.forEach { )
it.value.player.release()
it.value.release()
}
playingMap.clear()
} }
}
fun playingContent(): Collection<MediaSession> { @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
return playingMap.values fun getMediaSession(
} id: String,
uri: String,
callbackUri: String?,
context: Context,
applicationContext: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
if (existingSession != null) return existingSession
val player =
ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) }
build()
}
player.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
}
val mediaSession =
MediaSession.Builder(context, player).run {
callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) }
setId(id)
build()
}
player.addListener(
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
}
}
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
}
}
},
)
cache.put(id, mediaSession)
return mediaSession
}
@OptIn(DelicateCoroutinesApi::class)
fun releaseAppPlayers() {
GlobalScope.launch(Dispatchers.Main) {
cache.evictAll()
playingMap.forEach {
it.value.player.release()
it.value.release()
}
playingMap.clear()
}
}
fun playingContent(): Collection<MediaSession> {
return playingMap.values
}
} }
@@ -30,45 +30,45 @@ import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
object PlaybackClientController { object PlaybackClientController {
val cache = LruCache<Int, SessionToken>(1) val cache = LruCache<Int, SessionToken>(1)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun prepareController( fun prepareController(
controllerID: String, controllerID: String,
videoUri: String, videoUri: String,
callbackUri: String?, callbackUri: String?,
context: Context, context: Context,
onReady: (MediaController) -> Unit, onReady: (MediaController) -> Unit,
) { ) {
try { try {
// creating a bundle object // creating a bundle object
// creating a bundle object // creating a bundle object
val bundle = Bundle() val bundle = Bundle()
bundle.putString("id", controllerID) bundle.putString("id", controllerID)
bundle.putString("uri", videoUri) bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri) bundle.putString("callbackUri", callbackUri)
var session = cache.get(context.hashCode()) var session = cache.get(context.hashCode())
if (session == null) { if (session == null) {
session = SessionToken(context, ComponentName(context, PlaybackService::class.java)) session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
cache.put(context.hashCode(), session) cache.put(context.hashCode(), session)
} }
val controllerFuture = val controllerFuture =
MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync() MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync()
controllerFuture.addListener( controllerFuture.addListener(
{ {
try { try {
onReady(controllerFuture.get()) onReady(controllerFuture.get())
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor(),
)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
} }
},
MoreExecutors.directExecutor(),
)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
} }
}
} }
@@ -35,161 +35,161 @@ import com.vitorpamplona.amethyst.service.HttpClient
@UnstableApi // Extend MediaSessionService @UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
private var videoViewedPositionCache = VideoViewedPositionCache() private var videoViewedPositionCache = VideoViewedPositionCache()
private var managerHls: MultiPlayerPlaybackManager? = null private var managerHls: MultiPlayerPlaybackManager? = null
private var managerProgressive: MultiPlayerPlaybackManager? = null private var managerProgressive: MultiPlayerPlaybackManager? = null
private var managerLocal: MultiPlayerPlaybackManager? = null private var managerLocal: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory { fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()),
)
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let {
return it
} }
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) fun newProgressiveDataSource(): MediaSource.Factory {
managerHls = newInstance return ProgressiveMediaSource.Factory(
return newInstance (applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()),
} )
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let {
return it
} }
val newInstance = fun lazyHlsDS(): MultiPlayerPlaybackManager {
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) managerHls?.let {
managerProgressive = newInstance return it
return newInstance }
}
fun lazyLocalDS(): MultiPlayerPlaybackManager { val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerLocal?.let { managerHls = newInstance
return it return newInstance
} }
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerLocal = newInstance managerProgressive?.let {
return newInstance return it
} }
// Create your Player and MediaSession in the onCreate lifecycle event val newInstance =
@OptIn(UnstableApi::class) MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
override fun onCreate() { managerProgressive = newInstance
super.onCreate() return newInstance
Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
private fun onProxyUpdated() {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d("Lifetime Event", "onTaskRemoved")
}
override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
managerProgressive?.releaseAppPlayers()
super.onDestroy()
}
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
lazyHlsDS()
} else {
lazyProgressiveDS()
} }
}
override fun onUpdateNotification( fun lazyLocalDS(): MultiPlayerPlaybackManager {
session: MediaSession, managerLocal?.let {
startInForegroundRequired: Boolean, return it
) { }
// Updates any new player ready
super.onUpdateNotification(session, startInForegroundRequired)
// Overrides the notification with any player actually playing val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerHls?.playingContent()?.forEach { managerLocal = newInstance
if (it.player.isPlaying) { return newInstance
super.onUpdateNotification(it, startInForegroundRequired)
}
} }
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying) { // Create your Player and MediaSession in the onCreate lifecycle event
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
private fun onProxyUpdated() {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d("Lifetime Event", "onTaskRemoved")
}
override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
managerProgressive?.releaseAppPlayers()
super.onDestroy()
}
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
lazyHlsDS()
} else {
lazyProgressiveDS()
}
}
override fun onUpdateNotification(
session: MediaSession,
startInForegroundRequired: Boolean,
) {
// Updates any new player ready
super.onUpdateNotification(session, startInForegroundRequired) super.onUpdateNotification(session, startInForegroundRequired)
}
} // Overrides the notification with any player actually playing
managerProgressive?.playingContent()?.forEach { managerHls?.playingContent()?.forEach {
if (it.player.isPlaying) { if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired) super.onUpdateNotification(it, startInForegroundRequired)
} }
}
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
// Overrides again with playing with audio
managerHls?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
} }
// Overrides again with playing with audio // Return a MediaSession to link with the MediaController that is making
managerHls?.playingContent()?.forEach { // this request.
if (it.player.isPlaying && it.player.volume > 0) { override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
super.onUpdateNotification(it, startInForegroundRequired) val id = controllerInfo.connectionHints.getString("id") ?: return null
} val uri = controllerInfo.connectionHints.getString("uri") ?: return null
} val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
}
// Return a MediaSession to link with the MediaController that is making val manager = getAppropriateMediaSessionManager(uri)
// this request.
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
val id = controllerInfo.connectionHints.getString("id") ?: return null
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
val manager = getAppropriateMediaSessionManager(uri) return manager?.getMediaSession(
id,
return manager?.getMediaSession( uri,
id, callbackUri,
uri, context = this,
callbackUri, applicationContext = applicationContext,
context = this, )
applicationContext = applicationContext, }
)
}
} }
@@ -27,47 +27,47 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import java.io.File
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.File
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
class VideoCache { class VideoCache {
var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
lateinit var simpleCache: SimpleCache lateinit var simpleCache: SimpleCache
lateinit var cacheDataSourceFactory: CacheDataSource.Factory lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized @Synchronized
fun initFileCache(context: Context) { fun initFileCache(context: Context) {
exoDatabaseProvider = StandaloneDatabaseProvider(context) exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = simpleCache =
SimpleCache( SimpleCache(
File(context.cacheDir, "exoplayer"), File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor, leastRecentlyUsedCacheEvictor,
exoDatabaseProvider, exoDatabaseProvider,
) )
} }
// This method should be called when proxy setting changes. // This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) { fun renewCacheFactory(client: OkHttpClient) {
cacheDataSourceFactory = cacheDataSourceFactory =
CacheDataSource.Factory() CacheDataSource.Factory()
.setCache(simpleCache) .setCache(simpleCache)
.setUpstreamDataSourceFactory( .setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(client), OkHttpDataSource.Factory(client),
) )
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
} }
fun get(client: OkHttpClient): CacheDataSource.Factory { fun get(client: OkHttpClient): CacheDataSource.Factory {
// Renews the factory because OkHttpMight have changed. // Renews the factory because OkHttpMight have changed.
renewCacheFactory(client) renewCacheFactory(client)
return cacheDataSourceFactory return cacheDataSourceFactory
} }
} }
@@ -23,16 +23,16 @@ package com.vitorpamplona.amethyst.service.playback
import android.util.LruCache import android.util.LruCache
class VideoViewedPositionCache { class VideoViewedPositionCache {
val cachedPosition = LruCache<String, Long>(100) val cachedPosition = LruCache<String, Long>(100)
fun add( fun add(
uri: String, uri: String,
position: Long, position: Long,
) { ) {
cachedPosition.put(uri, position) cachedPosition.put(uri, position)
} }
fun get(uri: String): Long? { fun get(uri: String): Long? {
return cachedPosition.get(uri) return cachedPosition.get(uri)
} }
} }
@@ -24,20 +24,20 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) { class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) = suspend fun fetchUrlPreview(timeOut: Int = 30000) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
fetch(timeOut) fetch(timeOut)
} catch (t: Throwable) { } catch (t: Throwable) {
callback?.onFailed(t) callback?.onFailed(t)
} }
}
private suspend fun fetch(timeOut: Int = 30000) {
callback?.onComplete(getDocument(url, timeOut))
} }
private suspend fun fetch(timeOut: Int = 30000) { fun cleanUp() {
callback?.onComplete(getDocument(url, timeOut)) callback = null
} }
fun cleanUp() {
callback = null
}
} }
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.previews package com.vitorpamplona.amethyst.service.previews
interface IUrlPreviewCallback { interface IUrlPreviewCallback {
suspend fun onComplete(urlInfo: UrlInfoItem) suspend fun onComplete(urlInfo: UrlInfoItem)
suspend fun onFailed(throwable: Throwable) suspend fun onFailed(throwable: Throwable)
} }
@@ -21,30 +21,30 @@
package com.vitorpamplona.amethyst.service.previews package com.vitorpamplona.amethyst.service.previews
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import java.net.URL
import okhttp3.MediaType import okhttp3.MediaType
import java.net.URL
@Immutable @Immutable
class UrlInfoItem( class UrlInfoItem(
val url: String = "", val url: String = "",
val title: String = "", val title: String = "",
val description: String = "", val description: String = "",
val image: String = "", val image: String = "",
val mimeType: MediaType, val mimeType: MediaType,
) { ) {
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
val imageUrlFullPath = val imageUrlFullPath =
if (image.startsWith("/")) { if (image.startsWith("/")) {
URL(verifiedUrl, image).toString() URL(verifiedUrl, image).toString()
} else { } else {
image image
}
fun fetchComplete(): Boolean {
return url.isNotEmpty() && image.isNotEmpty()
} }
fun fetchComplete(): Boolean { fun allFetchComplete(): Boolean {
return url.isNotEmpty() && image.isNotEmpty() return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
} }
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
} }
@@ -36,147 +36,147 @@ private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
// for <meta itemprop=... to get title // for <meta itemprop=... to get title
private val META_X_TITLE = private val META_X_TITLE =
arrayOf( arrayOf(
"og:title", "og:title",
"\"og:title\"", "\"og:title\"",
"'og:title'", "'og:title'",
"name", "name",
"\"name\"", "\"name\"",
"'name'", "'name'",
"twitter:title", "twitter:title",
"\"twitter:title\"", "\"twitter:title\"",
"'twitter:title'", "'twitter:title'",
"title", "title",
"\"title\"", "\"title\"",
"'title'", "'title'",
) )
// for <meta itemprop=... to get description // for <meta itemprop=... to get description
private val META_X_DESCRIPTION = private val META_X_DESCRIPTION =
arrayOf( arrayOf(
"og:description", "og:description",
"\"og:description\"", "\"og:description\"",
"'og:description'", "'og:description'",
"description", "description",
"\"description\"", "\"description\"",
"'description'", "'description'",
"twitter:description", "twitter:description",
"\"twitter:description\"", "\"twitter:description\"",
"'twitter:description'", "'twitter:description'",
"description", "description",
"\"description\"", "\"description\"",
"'description'", "'description'",
) )
// for <meta itemprop=... to get image // for <meta itemprop=... to get image
private val META_X_IMAGE = private val META_X_IMAGE =
arrayOf( arrayOf(
"og:image", "og:image",
"\"og:image\"", "\"og:image\"",
"'og:image'", "'og:image'",
"image", "image",
"\"image\"", "\"image\"",
"'image'", "'image'",
"twitter:image", "twitter:image",
"\"twitter:image\"", "\"twitter:image\"",
"'twitter:image'", "'twitter:image'",
) )
private const val CONTENT = "content" private const val CONTENT = "content"
suspend fun getDocument( suspend fun getDocument(
url: String, url: String,
timeOut: Int = 30000, timeOut: Int = 30000,
): UrlInfoItem = ): UrlInfoItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build() val request: Request = Request.Builder().url(url).get().build()
HttpClient.getHttpClient().newCall(request).execute().use { HttpClient.getHttpClient().newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful) {
val mimeType = val mimeType =
it.headers.get("Content-Type")?.toMediaType() it.headers.get("Content-Type")?.toMediaType()
?: throw IllegalArgumentException( ?: throw IllegalArgumentException(
"Website returned unknown mimetype: ${it.headers.get("Content-Type")}", "Website returned unknown mimetype: ${it.headers.get("Content-Type")}",
) )
if (mimeType.type == "text" && mimeType.subtype == "html") { if (mimeType.type == "text" && mimeType.subtype == "html") {
val document = Jsoup.parse(it.body.string()) val document = Jsoup.parse(it.body.string())
parseHtml(url, document, mimeType) parseHtml(url, document, mimeType)
} else if (mimeType.type == "image") { } else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType) UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") { } else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType) UrlInfoItem(url, image = url, mimeType = mimeType)
} else { } else {
throw IllegalArgumentException( throw IllegalArgumentException(
"Website returned unknown encoding for previews: $mimeType", "Website returned unknown encoding for previews: $mimeType",
) )
}
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
} }
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
} }
}
suspend fun parseHtml( suspend fun parseHtml(
url: String, url: String,
document: Document, document: Document,
type: MediaType, type: MediaType,
): UrlInfoItem = ): UrlInfoItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META) val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
var title: String = "" var title: String = ""
var description: String = "" var description: String = ""
var image: String = "" var image: String = ""
metaTags.forEach { metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) { when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE -> in META_X_TITLE ->
if (title.isEmpty()) { if (title.isEmpty()) {
title = it.attr(CONTENT) title = it.attr(CONTENT)
} }
in META_X_DESCRIPTION -> in META_X_DESCRIPTION ->
if (description.isEmpty()) { if (description.isEmpty()) {
description = it.attr(CONTENT) description = it.attr(CONTENT)
} }
in META_X_IMAGE -> in META_X_IMAGE ->
if (image.isEmpty()) { if (image.isEmpty()) {
image = it.attr(CONTENT) image = it.attr(CONTENT)
} }
} }
when (it.attr(ATTRIBUTE_VALUE_NAME)) { when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE -> in META_X_TITLE ->
if (title.isEmpty()) { if (title.isEmpty()) {
title = it.attr(CONTENT) title = it.attr(CONTENT)
} }
in META_X_DESCRIPTION -> in META_X_DESCRIPTION ->
if (description.isEmpty()) { if (description.isEmpty()) {
description = it.attr(CONTENT) description = it.attr(CONTENT)
} }
in META_X_IMAGE -> in META_X_IMAGE ->
if (image.isEmpty()) { if (image.isEmpty()) {
image = it.attr(CONTENT) image = it.attr(CONTENT)
} }
} }
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) { when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE -> in META_X_TITLE ->
if (title.isEmpty()) { if (title.isEmpty()) {
title = it.attr(CONTENT) title = it.attr(CONTENT)
} }
in META_X_DESCRIPTION -> in META_X_DESCRIPTION ->
if (description.isEmpty()) { if (description.isEmpty()) {
description = it.attr(CONTENT) description = it.attr(CONTENT)
} }
in META_X_IMAGE -> in META_X_IMAGE ->
if (image.isEmpty()) { if (image.isEmpty()) {
image = it.attr(CONTENT) image = it.attr(CONTENT)
} }
} }
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) { if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image, type)
}
}
return@withContext UrlInfoItem(url, title, description, image, type) return@withContext UrlInfoItem(url, title, description, image, type)
}
} }
return@withContext UrlInfoItem(url, title, description, image, type)
}
@@ -24,293 +24,293 @@ import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.EventInterface
import java.util.UUID
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
/** /**
* The Nostr Client manages multiple personae the user may switch between. Events are received and * The Nostr Client manages multiple personae the user may switch between. Events are received and
* published through multiple relays. Events are stored with their respective persona. * published through multiple relays. Events are stored with their respective persona.
*/ */
object Client : RelayPool.Listener { object Client : RelayPool.Listener {
private var listeners = setOf<Listener>() private var listeners = setOf<Listener>()
private var relays = emptyArray<Relay>() private var relays = emptyArray<Relay>()
private var subscriptions = mapOf<String, List<TypedFilter>>() private var subscriptions = mapOf<String, List<TypedFilter>>()
@Synchronized @Synchronized
fun reconnect( fun reconnect(
relays: Array<Relay>?, relays: Array<Relay>?,
onlyIfChanged: Boolean = false, onlyIfChanged: Boolean = false,
) { ) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays") Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
checkNotInMainThread() checkNotInMainThread()
if (onlyIfChanged) { if (onlyIfChanged) {
if (!isSameRelaySetConfig(relays)) { if (!isSameRelaySetConfig(relays)) {
if (this.relays.isNotEmpty()) { if (this.relays.isNotEmpty()) {
RelayPool.disconnect() RelayPool.disconnect()
RelayPool.unregister(this) RelayPool.unregister(this)
RelayPool.unloadRelays() RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
} else {
if (this.relays.isNotEmpty()) {
RelayPool.disconnect()
RelayPool.unregister(this)
RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
if (relays.size != newRelayConfig?.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
} }
if (relays != null) { return true
RelayPool.register(this) }
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch() fun sendFilter(
this.relays = relays subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf(),
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf(),
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
}
fun send(
signedEvent: EventInterface,
relay: String? = null,
feedTypes: Set<FeedType>? = null,
relayList: List<Relay>? = null,
onDone: (() -> Unit)? = null,
) {
checkNotInMainThread()
if (relayList != null) {
RelayPool.sendToSelectedRelays(relayList, signedEvent)
} else if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
} else {
/** temporary connection */
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay -> relay.send(signedEvent) },
onDone = onDone,
)
}
} }
}
} else {
if (this.relays.isNotEmpty()) {
RelayPool.disconnect()
RelayPool.unregister(this)
RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
if (relays.size != newRelayConfig?.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
} }
return true @OptIn(DelicateCoroutinesApi::class)
} private fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
fun sendFilter( relay.connectAndRun {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10), allSubscriptions().forEach { relay.sendFilter(requestId = it) }
filters: List<TypedFilter> = listOf(),
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters) onConnected(relay)
RelayPool.sendFilter(subscriptionId)
}
fun sendFilterOnlyIfDisconnected( GlobalScope.launch(Dispatchers.IO) {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10), delay(60000) // waits for a reply
filters: List<TypedFilter> = listOf(), relay.disconnect()
) { RelayPool.removeRelay(relay)
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters) if (onDone != null) {
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) onDone()
} }
}
fun send(
signedEvent: EventInterface,
relay: String? = null,
feedTypes: Set<FeedType>? = null,
relayList: List<Relay>? = null,
onDone: (() -> Unit)? = null,
) {
checkNotInMainThread()
if (relayList != null) {
RelayPool.sendToSelectedRelays(relayList, signedEvent)
} else if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
} else {
/** temporary connection */
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay -> relay.send(signedEvent) },
onDone = onDone,
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
relay.connectAndRun {
allSubscriptions().forEach { relay.sendFilter(requestId = it) }
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(60000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
if (onDone != null) {
onDone()
} }
}
} }
}
fun close(subscriptionId: String) { fun close(subscriptionId: String) {
RelayPool.close(subscriptionId) RelayPool.close(subscriptionId)
subscriptions = subscriptions.minus(subscriptionId) subscriptions = subscriptions.minus(subscriptionId)
}
fun isActive(subscriptionId: String): Boolean {
return subscriptions.contains(subscriptionId)
}
@OptIn(DelicateCoroutinesApi::class)
override fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
} }
}
@OptIn(DelicateCoroutinesApi::class) fun isActive(subscriptionId: String): Boolean {
override fun onError( return subscriptions.contains(subscriptionId)
error: Error,
subscriptionId: String,
relay: Relay,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
} }
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onRelayStateChange( override fun onEvent(
type: Relay.StateType, event: Event,
relay: Relay, subscriptionId: String,
channel: String?, relay: Relay,
) { afterEOSE: Boolean,
// Releases the Web thread for the new payload. ) {
// May need to add a processing queue if processing new events become too costly. // Releases the Web thread for the new payload.
GlobalScope.launch(Dispatchers.Default) { // May need to add a processing queue if processing new events become too costly.
listeners.forEach { it.onRelayStateChange(type, relay, channel) } GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
} }
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onSendResponse( override fun onError(
eventId: String, error: Error,
success: Boolean, subscriptionId: String,
message: String, relay: Relay,
relay: Relay, ) {
) { // Releases the Web thread for the new payload.
// Releases the Web thread for the new payload. // May need to add a processing queue if processing new events become too costly.
// May need to add a processing queue if processing new events become too costly. GlobalScope.launch(Dispatchers.Default) {
GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onError(error, subscriptionId, relay) }
listeners.forEach { it.onSendResponse(eventId, success, message, relay) } }
} }
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onAuth( override fun onRelayStateChange(
relay: Relay, type: Relay.StateType,
challenge: String, relay: Relay,
) { channel: String?,
// Releases the Web thread for the new payload. ) {
// May need to add a processing queue if processing new events become too costly. // Releases the Web thread for the new payload.
GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } // May need to add a processing queue if processing new events become too costly.
} GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
override fun onNotify( }
relay: Relay,
description: String,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onNotify(relay, description) }
} }
}
fun subscribe(listener: Listener) { @OptIn(DelicateCoroutinesApi::class)
listeners = listeners.plus(listener) override fun onSendResponse(
} eventId: String,
success: Boolean,
message: String,
relay: Relay,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
}
fun isSubscribed(listener: Listener): Boolean { @OptIn(DelicateCoroutinesApi::class)
return listeners.contains(listener) override fun onAuth(
} relay: Relay,
challenge: String,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } }
}
fun unsubscribe(listener: Listener) { override fun onNotify(
listeners = listeners.minus(listener) relay: Relay,
} description: String,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onNotify(relay, description) }
}
}
fun allSubscriptions(): Set<String> { fun subscribe(listener: Listener) {
return subscriptions.keys listeners = listeners.plus(listener)
} }
fun getSubscriptionFilters(subId: String): List<TypedFilter> { fun isSubscribed(listener: Listener): Boolean {
return subscriptions[subId] ?: emptyList() return listeners.contains(listener)
} }
abstract class Listener { fun unsubscribe(listener: Listener) {
/** A new message was received */ listeners = listeners.minus(listener)
open fun onEvent( }
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
) = Unit
/** A new or repeat message was received */ fun allSubscriptions(): Set<String> {
open fun onError( return subscriptions.keys
error: Error, }
subscriptionId: String,
relay: Relay,
) = Unit
/** Connected to or disconnected from a relay */ fun getSubscriptionFilters(subId: String): List<TypedFilter> {
open fun onRelayStateChange( return subscriptions[subId] ?: emptyList()
type: Relay.StateType, }
relay: Relay,
channel: String?,
) = Unit
/** When an relay saves or rejects a new event. */ abstract class Listener {
open fun onSendResponse( /** A new message was received */
eventId: String, open fun onEvent(
success: Boolean, event: Event,
message: String, subscriptionId: String,
relay: Relay, relay: Relay,
) = Unit afterEOSE: Boolean,
) = Unit
open fun onAuth( /** A new or repeat message was received */
relay: Relay, open fun onError(
challenge: String, error: Error,
) = Unit subscriptionId: String,
relay: Relay,
) = Unit
open fun onNotify( /** Connected to or disconnected from a relay */
relay: Relay, open fun onRelayStateChange(
description: String, type: Relay.StateType,
) = Unit relay: Relay,
} channel: String?,
) = Unit
/** When an relay saves or rejects a new event. */
open fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
) = Unit
open fun onAuth(
relay: Relay,
challenge: String,
) = Unit
open fun onNotify(
relay: Relay,
description: String,
) = Unit
}
} }
@@ -23,156 +23,156 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants { object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS)
val activeTypesGlobalChats = val activeTypesGlobalChats =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val activeTypesSearch = setOf(FeedType.SEARCH) val activeTypesSearch = setOf(FeedType.SEARCH)
fun convertDefaultRelays(): Array<Relay> { fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray()
} }
val defaultRelays = val defaultRelays =
arrayOf( arrayOf(
// Free relays for only DMs and Follows due to the amount of spam // Free relays for only DMs and Follows due to the amount of spam
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
// Chats // Chats
RelaySetupInfo( RelaySetupInfo(
"wss://nostr.bitcoiner.social", "wss://nostr.bitcoiner.social",
read = true, read = true,
write = true, write = true,
feedTypes = activeTypesChats, feedTypes = activeTypesChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://relay.nostr.bg", "wss://relay.nostr.bg",
read = true, read = true,
write = true, write = true,
feedTypes = activeTypesChats, feedTypes = activeTypesChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://nostr.oxtr.dev", "wss://nostr.oxtr.dev",
read = true, read = true,
write = true, write = true,
feedTypes = activeTypesChats, feedTypes = activeTypesChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://nostr-pub.wellorder.net", "wss://nostr-pub.wellorder.net",
read = true, read = true,
write = true, write = true,
feedTypes = activeTypesChats, feedTypes = activeTypesChats,
), ),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats), RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats), RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
// Less Reliable // Less Reliable
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true,
// feedTypes = activeTypes), // feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes
// = activeTypes), // = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true,
// feedTypes = activeTypes), // feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes =
// activeTypes), // activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true,
// feedTypes = activeTypes), // feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes =
// activeTypes), // activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes
// = activeTypes), // = activeTypes),
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes =
// activeTypes), // activeTypes),
// Paid relays // Paid relays
RelaySetupInfo( RelaySetupInfo(
"wss://relay.snort.social", "wss://relay.snort.social",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://relay.nostr.com.au", "wss://relay.nostr.com.au",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://eden.nostr.land", "wss://eden.nostr.land",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://nostr.milou.lol", "wss://nostr.milou.lol",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://puravida.nostr.land", "wss://puravida.nostr.land",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://nostr.wine", "wss://nostr.wine",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://nostr.inosta.cc", "wss://nostr.inosta.cc",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://atlas.nostr.land", "wss://atlas.nostr.land",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://relay.orangepill.dev", "wss://relay.orangepill.dev",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
RelaySetupInfo( RelaySetupInfo(
"wss://relay.nostrati.com", "wss://relay.nostrati.com",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesGlobalChats, feedTypes = activeTypesGlobalChats,
), ),
// Supporting NIP-50 // Supporting NIP-50
RelaySetupInfo( RelaySetupInfo(
"wss://relay.nostr.band", "wss://relay.nostr.band",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesSearch, feedTypes = activeTypesSearch,
), ),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo( RelaySetupInfo(
"wss://relay.noswhere.com", "wss://relay.noswhere.com",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesSearch, feedTypes = activeTypesSearch,
), ),
) )
val forcedRelayForSearch = val forcedRelayForSearch =
arrayOf( arrayOf(
RelaySetupInfo( RelaySetupInfo(
"wss://relay.nostr.band", "wss://relay.nostr.band",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesSearch, feedTypes = activeTypesSearch,
), ),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo( RelaySetupInfo(
"wss://relay.noswhere.com", "wss://relay.noswhere.com",
read = true, read = true,
write = false, write = false,
feedTypes = activeTypesSearch, feedTypes = activeTypesSearch,
), ),
) )
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url } val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
} }
@@ -23,60 +23,60 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
class EOSETime(var time: Long) { class EOSETime(var time: Long) {
override fun toString(): String { override fun toString(): String {
return time.toString() return time.toString()
} }
} }
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) { class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
fun addOrUpdate( fun addOrUpdate(
relayUrl: String, relayUrl: String,
time: Long, time: Long,
) { ) {
val eose = relayList[relayUrl] val eose = relayList[relayUrl]
if (eose == null) { if (eose == null) {
relayList = relayList + Pair(relayUrl, EOSETime(time)) relayList = relayList + Pair(relayUrl, EOSETime(time))
} else { } else {
eose.time = time eose.time = time
}
} }
}
} }
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) { class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
fun addOrUpdate( fun addOrUpdate(
listCode: String, listCode: String,
relayUrl: String, relayUrl: String,
time: Long, time: Long,
) { ) {
val relayList = followList[listCode] val relayList = followList[listCode]
if (relayList == null) { if (relayList == null) {
val newList = EOSERelayList() val newList = EOSERelayList()
newList.addOrUpdate(relayUrl, time) newList.addOrUpdate(relayUrl, time)
followList = followList + mapOf(listCode to newList) followList = followList + mapOf(listCode to newList)
} else { } else {
relayList.addOrUpdate(relayUrl, time) relayList.addOrUpdate(relayUrl, time)
}
} }
}
} }
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) { class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
fun addOrUpdate( fun addOrUpdate(
user: User, user: User,
listCode: String, listCode: String,
relayUrl: String, relayUrl: String,
time: Long, time: Long,
) { ) {
val followList = users[user] val followList = users[user]
if (followList == null) { if (followList == null) {
val newList = EOSEFollowList() val newList = EOSEFollowList()
newList.addOrUpdate(listCode, relayUrl, time) newList.addOrUpdate(listCode, relayUrl, time)
users = users + mapOf(user to newList) users = users + mapOf(user to newList)
} else { } else {
followList.addOrUpdate(listCode, relayUrl, time) followList.addOrUpdate(listCode, relayUrl, time)
}
} }
}
fun removeDataFor(user: User) { fun removeDataFor(user: User) {
users = users.minus(user) users = users.minus(user)
} }
} }
@@ -23,65 +23,65 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
class JsonFilter( class JsonFilter(
val ids: List<String>? = null, val ids: List<String>? = null,
val authors: List<String>? = null, val authors: List<String>? = null,
val kinds: List<Int>? = null, val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null, val tags: Map<String, List<String>>? = null,
val since: Map<String, EOSETime>? = null, val since: Map<String, EOSETime>? = null,
val until: Long? = null, val until: Long? = null,
val limit: Int? = null, val limit: Int? = null,
val search: String? = null, val search: String? = null,
) { ) {
fun toJson(forRelay: String? = null): String { fun toJson(forRelay: String? = null): String {
val factory = Event.mapper.nodeFactory val factory = Event.mapper.nodeFactory
val filter = val filter =
factory.objectNode().apply { factory.objectNode().apply {
ids?.run { ids?.run {
put( put(
"ids", "ids",
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } }, factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
) )
} }
authors?.run { authors?.run {
put( put(
"authors", "authors",
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } }, factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
) )
} }
kinds?.run { kinds?.run {
put( put(
"kinds", "kinds",
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } }, factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
) )
} }
tags?.run { tags?.run {
entries.forEach { kv -> entries.forEach { kv ->
put( put(
"#${kv.key}", "#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
) )
} }
} }
since?.run { since?.run {
if (!isEmpty()) { if (!isEmpty()) {
if (forRelay != null) { if (forRelay != null) {
val relaySince = get(forRelay) val relaySince = get(forRelay)
if (relaySince != null) { if (relaySince != null) {
put("since", relaySince.time) put("since", relaySince.time)
} }
} else { } else {
val jsonObjectSince = factory.objectNode() val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs -> entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
} }
put("since", jsonObjectSince) put("since", jsonObjectSince)
}
}
}
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
} }
} return Event.mapper.writeValueAsString(filter)
} }
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
return Event.mapper.writeValueAsString(filter)
}
} }
@@ -31,501 +31,501 @@ import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.bytesUsedInMemory import com.vitorpamplona.quartz.events.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.lang.StringBuilder
import java.util.concurrent.atomic.AtomicBoolean
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.lang.StringBuilder
import java.util.concurrent.atomic.AtomicBoolean
enum class FeedType { enum class FeedType {
FOLLOWS, FOLLOWS,
PUBLIC_CHATS, PUBLIC_CHATS,
PRIVATE_DMS, PRIVATE_DMS,
GLOBAL, GLOBAL,
SEARCH, SEARCH,
WALLET_CONNECT, WALLET_CONNECT,
} }
val COMMON_FEED_TYPES = val COMMON_FEED_TYPES =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
class Relay( class Relay(
val url: String, val url: String,
val read: Boolean = true, val read: Boolean = true,
val write: Boolean = true, val write: Boolean = true,
val activeTypes: Set<FeedType> = FeedType.values().toSet(), val activeTypes: Set<FeedType> = FeedType.values().toSet(),
) { ) {
val brief = RelayBriefInfoCache.get(url) val brief = RelayBriefInfoCache.get(url)
companion object { companion object {
// waits 3 minutes to reconnect once things fail // waits 3 minutes to reconnect once things fail
const val RECONNECTING_IN_SECONDS = 60 * 3 const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient = HttpClient.getHttpClientForRelays()
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
private var usingCompression: Boolean = false
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
var spamCounter = 0
var errorCounter = 0
var pingInMs: Long? = null
var closingTimeInSeconds = 0L
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
val authResponse = mutableMapOf<HexKey, Boolean>()
fun register(listener: Listener) {
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
fun isConnected(): Boolean {
return socket != null
}
fun connect() {
connectAndRun {
checkNotInMainThread()
// Sends everything.
renewFilters()
}
}
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
// If there is a connection, don't wait.
if (connectingBlock.getAndSet(true)) {
return
} }
checkNotInMainThread() private val httpClient = HttpClient.getHttpClientForRelays()
if (socket != null) return private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
private var usingCompression: Boolean = false
try { var eventDownloadCounterInBytes = 0
val request = var eventUploadCounterInBytes = 0
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
socket = httpClient.newWebSocket(request, RelayListener(onConnected)) var spamCounter = 0
} catch (e: Exception) { var errorCounter = 0
errorCounter++ var pingInMs: Long? = null
markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
} finally {
connectingBlock.set(false)
}
}
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() { var closingTimeInSeconds = 0L
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
checkNotInMainThread()
Log.d("Relay", "Connect onOpen $url $socket")
markConnectionAsReady( var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
usingCompression =
response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false,
)
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") val authResponse = mutableMapOf<HexKey, Boolean>()
onConnected(this@Relay)
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } fun register(listener: Listener) {
listeners = listeners.plus(listener)
} }
override fun onMessage( fun unregister(listener: Listener) {
webSocket: WebSocket, listeners = listeners.minus(listener)
text: String,
) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
processNewRelayMessage(text)
} catch (t: Throwable) {
t.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
}
}
} }
override fun onClosing( fun isConnected(): Boolean {
webSocket: WebSocket, return socket != null
code: Int,
reason: String,
) {
checkNotInMainThread()
Log.w("Relay", "Relay onClosing $url: $reason")
listeners.forEach {
it.onRelayStateChange(
this@Relay,
StateType.DISCONNECTING,
null,
)
}
} }
override fun onClosed( fun connect() {
webSocket: WebSocket, connectAndRun {
code: Int,
reason: String,
) {
checkNotInMainThread()
markConnectionAsClosed()
Log.w("Relay", "Relay onClosed $url: $reason")
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
checkNotInMainThread()
errorCounter++
socket?.cancel() // 1000, "Normal close"
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
}
fun markConnectionAsReady(
pingInMs: Long,
usingCompression: Boolean,
) {
this.resetEOSEStatuses()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
}
fun markConnectionAsClosed() {
this.socket = null
this.isReady = false
this.usingCompression = false
this.resetEOSEStatuses()
this.closingTimeInSeconds = TimeUtils.now()
}
fun processNewRelayMessage(newMessage: String) {
val msgArray = Event.mapper.readTree(newMessage)
when (val type = msgArray.get(0).asText()) {
"EVENT" -> {
val subscriptionId = msgArray.get(1).asText()
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(
this@Relay,
subscriptionId,
event,
afterEOSEPerSubscription[subscriptionId] == true,
)
}
}
"EOSE" ->
listeners.forEach {
val subscriptionId = msgArray.get(1).asText()
afterEOSEPerSubscription[subscriptionId] = true
// Log.w("Relay", "Relay onEOSE $url $subscriptionId")
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
}
"NOTICE" ->
listeners.forEach {
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" ->
listeners.forEach {
val eventId = msgArray[1].asText()
val success = msgArray[2].asBoolean()
val message = if (msgArray.size() > 2) msgArray[3].asText() else ""
if (authResponse.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponse.get(eventId)
authResponse.put(eventId, success)
if (wasAlreadyAuthenticated != true && success) {
renewFilters()
}
}
Log.w("Relay", "Relay on OK $url, $eventId, $success, $message")
it.onSendResponse(this@Relay, eventId, success, message)
}
"AUTH" ->
listeners.forEach {
// Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText())
}
"NOTIFY" ->
listeners.forEach {
// Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}")
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") }
else ->
listeners.forEach {
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
this@Relay,
"",
Error("Unknown type $type on channel. Msg was $newMessage"),
)
}
}
}
fun disconnect() {
Log.d("Relay", "Relay.disconnect $url")
checkNotInMainThread()
closingTimeInSeconds = TimeUtils.now()
socket?.cancel()
socket = null
isReady = false
usingCompression = false
resetEOSEStatuses()
}
fun resetEOSEStatuses() {
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
}
fun sendFilter(requestId: String) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
val filters =
Client.getSubscriptionFilters(requestId).filter { filter ->
activeTypes.any { it in filter.types }
}
if (filters.isNotEmpty()) {
val request =
filters.joinToStringLimited(
separator = ",",
limit = 20,
prefix = """["REQ","$requestId",""",
postfix = "]",
) {
it.filter.toJson(url)
}
// Log.d("Relay", "onFilterSent $url $requestId $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
resetEOSEStatuses()
}
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connect()
}
}
}
}
fun <T> Iterable<T>.joinToStringLimited(
separator: CharSequence = ", ",
prefix: CharSequence = "",
postfix: CharSequence = "",
limit: Int = -1,
transform: ((T) -> CharSequence)? = null,
): String {
val buffer = StringBuilder()
buffer.append(prefix)
var count = 0
for (element in this) {
if (limit < 0 || count <= limit) {
if (++count > 1) buffer.append(separator)
when {
transform != null -> buffer.append(transform(element))
element is CharSequence? -> buffer.append(element)
element is Char -> buffer.append(element)
else -> buffer.append(element.toString())
}
} else {
break
}
}
buffer.append(postfix)
return buffer.toString()
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
checkNotInMainThread()
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// println("sendfilter Only if Disconnected ${url} ")
connect()
}
}
}
fun renewFilters() {
// Force update all filters after AUTH.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
} else {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread() checkNotInMainThread()
// Sends everything.
renewFilters()
}
}
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
// If there is a connection, don't wait.
if (connectingBlock.getAndSet(true)) {
return
}
checkNotInMainThread()
if (socket != null) return
try {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) {
errorCounter++
markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
} finally {
connectingBlock.set(false)
}
}
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
checkNotInMainThread()
Log.d("Relay", "Connect onOpen $url $socket")
markConnectionAsReady(
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
usingCompression =
response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false,
)
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
processNewRelayMessage(text)
} catch (t: Throwable) {
t.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
}
}
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
checkNotInMainThread()
Log.w("Relay", "Relay onClosing $url: $reason")
listeners.forEach {
it.onRelayStateChange(
this@Relay,
StateType.DISCONNECTING,
null,
)
}
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
checkNotInMainThread()
markConnectionAsClosed()
Log.w("Relay", "Relay onClosed $url: $reason")
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
checkNotInMainThread()
errorCounter++
socket?.cancel() // 1000, "Normal close"
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
}
fun markConnectionAsReady(
pingInMs: Long,
usingCompression: Boolean,
) {
this.resetEOSEStatuses()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
}
fun markConnectionAsClosed() {
this.socket = null
this.isReady = false
this.usingCompression = false
this.resetEOSEStatuses()
this.closingTimeInSeconds = TimeUtils.now()
}
fun processNewRelayMessage(newMessage: String) {
val msgArray = Event.mapper.readTree(newMessage)
when (val type = msgArray.get(0).asText()) {
"EVENT" -> {
val subscriptionId = msgArray.get(1).asText()
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(
this@Relay,
subscriptionId,
event,
afterEOSEPerSubscription[subscriptionId] == true,
)
}
}
"EOSE" ->
listeners.forEach {
val subscriptionId = msgArray.get(1).asText()
afterEOSEPerSubscription[subscriptionId] = true
// Log.w("Relay", "Relay onEOSE $url $subscriptionId")
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
}
"NOTICE" ->
listeners.forEach {
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" ->
listeners.forEach {
val eventId = msgArray[1].asText()
val success = msgArray[2].asBoolean()
val message = if (msgArray.size() > 2) msgArray[3].asText() else ""
if (authResponse.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponse.get(eventId)
authResponse.put(eventId, success)
if (wasAlreadyAuthenticated != true && success) {
renewFilters()
}
}
Log.w("Relay", "Relay on OK $url, $eventId, $success, $message")
it.onSendResponse(this@Relay, eventId, success, message)
}
"AUTH" ->
listeners.forEach {
// Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText())
}
"NOTIFY" ->
listeners.forEach {
// Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}")
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") }
else ->
listeners.forEach {
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
this@Relay,
"",
Error("Unknown type $type on channel. Msg was $newMessage"),
)
}
}
}
fun disconnect() {
Log.d("Relay", "Relay.disconnect $url")
checkNotInMainThread()
closingTimeInSeconds = TimeUtils.now()
socket?.cancel()
socket = null
isReady = false
usingCompression = false
resetEOSEStatuses()
}
fun resetEOSEStatuses() {
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
}
fun sendFilter(requestId: String) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
val filters =
Client.getSubscriptionFilters(requestId).filter { filter ->
activeTypes.any { it in filter.types }
}
if (filters.isNotEmpty()) {
val request =
filters.joinToStringLimited(
separator = ",",
limit = 20,
prefix = """["REQ","$requestId",""",
postfix = "]",
) {
it.filter.toJson(url)
}
// Log.d("Relay", "onFilterSent $url $requestId $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
resetEOSEStatuses()
}
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connect()
}
}
}
}
fun <T> Iterable<T>.joinToStringLimited(
separator: CharSequence = ", ",
prefix: CharSequence = "",
postfix: CharSequence = "",
limit: Int = -1,
transform: ((T) -> CharSequence)? = null,
): String {
val buffer = StringBuilder()
buffer.append(prefix)
var count = 0
for (element in this) {
if (limit < 0 || count <= limit) {
if (++count > 1) buffer.append(separator)
when {
transform != null -> buffer.append(transform(element))
element is CharSequence? -> buffer.append(element)
element is Char -> buffer.append(element)
else -> buffer.append(element.toString())
}
} else {
break
}
}
buffer.append(postfix)
return buffer.toString()
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
checkNotInMainThread()
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// println("sendfilter Only if Disconnected ${url} ")
connect()
}
}
}
fun renewFilters() {
// Force update all filters after AUTH.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event) socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory() eventUploadCounterInBytes += event.bytesUsedInMemory()
} else {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
// Sends everything. socket?.send(event)
Client.allSubscriptions().forEach { sendFilter(requestId = it) } eventUploadCounterInBytes += event.bytesUsedInMemory()
}
// Sends everything.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
}
}
}
} }
}
} }
}
fun close(subscriptionId: String) { fun close(subscriptionId: String) {
checkNotInMainThread() checkNotInMainThread()
val msg = """["CLOSE","$subscriptionId"]""" val msg = """["CLOSE","$subscriptionId"]"""
// Log.d("Relay", "Close Subscription $url $msg") // Log.d("Relay", "Close Subscription $url $msg")
socket?.send(msg) socket?.send(msg)
} }
fun isSameRelayConfig(other: Relay): Boolean { fun isSameRelayConfig(other: Relay): Boolean {
return url == other.url && return url == other.url &&
write == other.write && write == other.write &&
read == other.read && read == other.read &&
activeTypes == other.activeTypes activeTypes == other.activeTypes
} }
enum class StateType { enum class StateType {
// Websocket connected // Websocket connected
CONNECT, CONNECT,
// Websocket disconnecting // Websocket disconnecting
DISCONNECTING, DISCONNECTING,
// Websocket disconnected // Websocket disconnected
DISCONNECT, DISCONNECT,
// End Of Stored Events // End Of Stored Events
EOSE, EOSE,
} }
interface Listener { interface Listener {
/** A new message was received */ /** A new message was received */
fun onEvent( fun onEvent(
relay: Relay, relay: Relay,
subscriptionId: String, subscriptionId: String,
event: Event, event: Event,
afterEOSE: Boolean, afterEOSE: Boolean,
) )
fun onError( fun onError(
relay: Relay, relay: Relay,
subscriptionId: String, subscriptionId: String,
error: Error, error: Error,
) )
fun onSendResponse( fun onSendResponse(
relay: Relay, relay: Relay,
eventId: String, eventId: String,
success: Boolean, success: Boolean,
message: String, message: String,
) )
fun onAuth( fun onAuth(
relay: Relay, relay: Relay,
challenge: String, challenge: String,
) )
/** /**
* Connected to or disconnected from a relay * Connected to or disconnected from a relay
* *
* @param type is 0 for disconnect and 1 for connect * @param type is 0 for disconnect and 1 for connect
*/ */
fun onRelayStateChange( fun onRelayStateChange(
relay: Relay, relay: Relay,
type: StateType, type: StateType,
channel: String?, channel: String?,
) )
/** Relay sent an invoice */ /** Relay sent an invoice */
fun onNotify( fun onNotify(
relay: Relay, relay: Relay,
description: String, description: String,
) )
} }
} }
@@ -33,200 +33,200 @@ import kotlinx.coroutines.flow.asSharedFlow
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/ */
object RelayPool : Relay.Listener { object RelayPool : Relay.Listener {
private var relays = listOf<Relay>() private var relays = listOf<Relay>()
private var listeners = setOf<Listener>() private var listeners = setOf<Listener>()
// Backing property to avoid flow emissions from other classes // Backing property to avoid flow emissions from other classes
private var lastStatus = RelayPoolStatus(0, 0) private var lastStatus = RelayPoolStatus(0, 0)
private val _statusFlow = private val _statusFlow =
MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow() val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow()
fun availableRelays(): Int { fun availableRelays(): Int {
return relays.size return relays.size
}
fun connectedRelays(): Int {
return relays.count { it.isConnected() }
}
fun getRelay(url: String): Relay? {
return relays.firstOrNull { it.url == url }
}
fun getRelays(url: String): List<Relay> {
return relays.filter { it.url == url }
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) }
} else {
Constants.convertDefaultRelays().forEach { addRelay(it) }
} }
}
fun unloadRelays() { fun connectedRelays(): Int {
relays.forEach { it.unregister(this) } return relays.count { it.isConnected() }
relays = listOf()
}
fun requestAndWatch() {
checkNotInMainThread()
relays.forEach { it.connect() }
}
fun sendFilter(subscriptionId: String) {
relays.forEach { it.sendFilter(subscriptionId) }
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
}
fun sendToSelectedRelays(
list: List<Relay>,
signedEvent: EventInterface,
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } }
}
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent) }
}
fun close(subscriptionId: String) {
relays.forEach { it.close(subscriptionId) }
}
fun disconnect() {
relays.forEach { it.disconnect() }
}
fun addRelay(relay: Relay) {
relay.register(this)
relays += relay
updateStatus()
}
fun removeRelay(relay: Relay) {
relay.unregister(this)
relays = relays.minus(relay)
updateStatus()
}
fun register(listener: Listener) {
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
interface Listener {
fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
)
fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
)
fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
channel: String?,
)
fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
)
fun onAuth(
relay: Relay,
challenge: String,
)
fun onNotify(
relay: Relay,
description: String,
)
}
override fun onEvent(
relay: Relay,
subscriptionId: String,
event: Event,
afterEOSE: Boolean,
) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
override fun onError(
relay: Relay,
subscriptionId: String,
error: Error,
) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
updateStatus()
}
override fun onRelayStateChange(
relay: Relay,
type: Relay.StateType,
channel: String?,
) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
if (type != Relay.StateType.EOSE) {
updateStatus()
} }
}
override fun onSendResponse( fun getRelay(url: String): Relay? {
relay: Relay, return relays.firstOrNull { it.url == url }
eventId: String, }
success: Boolean,
message: String, fun getRelays(url: String): List<Relay> {
) { return relays.filter { it.url == url }
listeners.forEach { it.onSendResponse(eventId, success, message, relay) } }
}
fun loadRelays(relayList: List<Relay>) {
override fun onAuth( if (!relayList.isNullOrEmpty()) {
relay: Relay, relayList.forEach { addRelay(it) }
challenge: String, } else {
) { Constants.convertDefaultRelays().forEach { addRelay(it) }
listeners.forEach { it.onAuth(relay, challenge) } }
} }
override fun onNotify( fun unloadRelays() {
relay: Relay, relays.forEach { it.unregister(this) }
description: String, relays = listOf()
) { }
listeners.forEach { it.onNotify(relay, description) }
} fun requestAndWatch() {
checkNotInMainThread()
private fun updateStatus() {
val connected = connectedRelays() relays.forEach { it.connect() }
val available = availableRelays() }
if (lastStatus.connected != connected || lastStatus.available != available) {
lastStatus = RelayPoolStatus(connected, available) fun sendFilter(subscriptionId: String) {
_statusFlow.tryEmit(lastStatus) relays.forEach { it.sendFilter(subscriptionId) }
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
}
fun sendToSelectedRelays(
list: List<Relay>,
signedEvent: EventInterface,
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } }
}
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent) }
}
fun close(subscriptionId: String) {
relays.forEach { it.close(subscriptionId) }
}
fun disconnect() {
relays.forEach { it.disconnect() }
}
fun addRelay(relay: Relay) {
relay.register(this)
relays += relay
updateStatus()
}
fun removeRelay(relay: Relay) {
relay.unregister(this)
relays = relays.minus(relay)
updateStatus()
}
fun register(listener: Listener) {
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
interface Listener {
fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean,
)
fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
)
fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
channel: String?,
)
fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay,
)
fun onAuth(
relay: Relay,
challenge: String,
)
fun onNotify(
relay: Relay,
description: String,
)
}
override fun onEvent(
relay: Relay,
subscriptionId: String,
event: Event,
afterEOSE: Boolean,
) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
override fun onError(
relay: Relay,
subscriptionId: String,
error: Error,
) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
updateStatus()
}
override fun onRelayStateChange(
relay: Relay,
type: Relay.StateType,
channel: String?,
) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
if (type != Relay.StateType.EOSE) {
updateStatus()
}
}
override fun onSendResponse(
relay: Relay,
eventId: String,
success: Boolean,
message: String,
) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
override fun onAuth(
relay: Relay,
challenge: String,
) {
listeners.forEach { it.onAuth(relay, challenge) }
}
override fun onNotify(
relay: Relay,
description: String,
) {
listeners.forEach { it.onNotify(relay, description) }
}
private fun updateStatus() {
val connected = connectedRelays()
val available = availableRelays()
if (lastStatus.connected != connected || lastStatus.available != available) {
lastStatus = RelayPoolStatus(connected, available)
_statusFlow.tryEmit(lastStatus)
}
} }
}
} }
@Immutable @Immutable
data class RelayPoolStatus( data class RelayPoolStatus(
val connected: Int, val connected: Int,
val available: Int, val available: Int,
val isConnected: Boolean = connected > 0, val isConnected: Boolean = connected > 0,
) )
@@ -25,35 +25,35 @@ import com.vitorpamplona.quartz.events.Event
import java.util.UUID import java.util.UUID
data class Subscription( data class Subscription(
val id: String = UUID.randomUUID().toString().substring(0, 4), val id: String = UUID.randomUUID().toString().substring(0, 4),
val onEOSE: ((Long, String) -> Unit)? = null, val onEOSE: ((Long, String) -> Unit)? = null,
) { ) {
var typedFilters: List<TypedFilter>? = null // Inactive when null var typedFilters: List<TypedFilter>? = null // Inactive when null
fun updateEOSE( fun updateEOSE(
time: Long, time: Long,
relay: String, relay: String,
) { ) {
onEOSE?.let { it(time, relay) } onEOSE?.let { it(time, relay) }
} }
fun toJson(): String { fun toJson(): String {
return Event.mapper.writeValueAsString(toJsonObject()) return Event.mapper.writeValueAsString(toJsonObject())
} }
fun toJsonObject(): JsonNode { fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory val factory = Event.mapper.nodeFactory
return factory.objectNode().apply { return factory.objectNode().apply {
put("id", id) put("id", id)
typedFilters?.also { filters -> typedFilters?.also { filters ->
put( put(
"typedFilters", "typedFilters",
factory.arrayNode(filters.size).apply { factory.arrayNode(filters.size).apply {
filters.forEach { filter -> add(filter.toJsonObject()) } filters.forEach { filter -> add(filter.toJsonObject()) }
}, },
) )
} }
}
} }
}
} }
@@ -25,56 +25,56 @@ import com.fasterxml.jackson.databind.node.ArrayNode
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
class TypedFilter( class TypedFilter(
val types: Set<FeedType>, val types: Set<FeedType>,
val filter: JsonFilter, val filter: JsonFilter,
) { ) {
fun toJson(): String { fun toJson(): String {
return Event.mapper.writeValueAsString(toJsonObject()) return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
put("types", typesToJson(types))
put("filter", filterToJson(filter))
} }
}
fun typesToJson(types: Set<FeedType>): ArrayNode { fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory val factory = Event.mapper.nodeFactory
return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } }
}
fun filterToJson(filter: JsonFilter): JsonNode { return factory.objectNode().apply {
val factory = Event.mapper.nodeFactory put("types", typesToJson(types))
return factory.objectNode().apply { put("filter", filterToJson(filter))
filter.ids?.run {
put(
"ids",
factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
)
}
filter.authors?.run {
put(
"authors",
factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
)
}
filter.kinds?.run {
put(
"kinds",
factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
)
}
filter.tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
} }
} }
fun typesToJson(types: Set<FeedType>): ArrayNode {
val factory = Event.mapper.nodeFactory
return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } }
}
fun filterToJson(filter: JsonFilter): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
filter.ids?.run {
put(
"ids",
factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
)
}
filter.authors?.run {
put(
"authors",
factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
)
}
filter.kinds?.run {
put(
"kinds",
factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
)
}
filter.tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
}
}
/* /*
Does not include since in the json comparison Does not include since in the json comparison
filter.since?.run { filter.since?.run {
@@ -84,9 +84,9 @@ class TypedFilter(
} }
jsonObject.add("since", jsonObjectSince) jsonObject.add("since", jsonObjectSince)
}*/ }*/
filter.until?.run { put("until", filter.until) } filter.until?.run { put("until", filter.until) }
filter.limit?.run { put("limit", filter.limit) } filter.limit?.run { put("limit", filter.limit) }
filter.search?.run { put("search", filter.search) } filter.search?.run { put("search", filter.search) }
}
} }
}
} }
@@ -28,160 +28,159 @@ import java.util.Locale
const val DEF_SPEECH_AND_PITCH = 0.8f const val DEF_SPEECH_AND_PITCH = 0.8f
fun getErrorText(errorCode: Int): String = fun getErrorText(errorCode: Int): String =
when (errorCode) { when (errorCode) {
TextToSpeech.ERROR -> "ERROR" TextToSpeech.ERROR -> "ERROR"
TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST"
TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK" TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK"
TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT" TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT"
TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE" TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE"
TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS" TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS"
TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET" TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET"
else -> "UNKNOWN" else -> "UNKNOWN"
} }
class TextToSpeechEngine private constructor() { class TextToSpeechEngine private constructor() {
private var tts: TextToSpeech? = null private var tts: TextToSpeech? = null
private var defaultPitch = 0.8f private var defaultPitch = 0.8f
private var defaultSpeed = 0.8f private var defaultSpeed = 0.8f
private var defLanguage = Locale.getDefault() private var defLanguage = Locale.getDefault()
private var onStartListener: (() -> Unit)? = null private var onStartListener: (() -> Unit)? = null
private var onDoneListener: (() -> Unit)? = null private var onDoneListener: (() -> Unit)? = null
private var onErrorListener: ((String) -> Unit)? = null private var onErrorListener: ((String) -> Unit)? = null
private var onHighlightListener: ((Int, Int) -> Unit)? = null private var onHighlightListener: ((Int, Int) -> Unit)? = null
private var message: String? = null private var message: String? = null
companion object { companion object {
private var instance: TextToSpeechEngine? = null private var instance: TextToSpeechEngine? = null
fun getInstance(): TextToSpeechEngine { fun getInstance(): TextToSpeechEngine {
if (instance == null) { if (instance == null) {
instance = TextToSpeechEngine() instance = TextToSpeechEngine()
} }
return instance!! return instance!!
}
}
fun initTTS(
context: Context,
message: String,
) {
tts =
TextToSpeech(context) {
if (it == TextToSpeech.SUCCESS) {
tts?.let {
it.language = defLanguage
it.setPitch(defaultPitch)
it.setSpeechRate(defaultSpeed)
it.setListener(
onStart = { onStartListener?.invoke() },
onError = { e -> e?.let { error -> onErrorListener?.invoke(error) } },
onRange = { start, end ->
if (this@TextToSpeechEngine.message != null) {
onHighlightListener?.invoke(start, end)
}
},
onDone = { onStartListener?.invoke() },
)
speak(message)
}
} else {
onErrorListener?.invoke(getErrorText(it))
} }
} }
}
private fun speak(message: String): TextToSpeechEngine { fun initTTS(
tts?.speak( context: Context,
message, message: String,
TextToSpeech.QUEUE_FLUSH, ) {
null, tts =
TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED, TextToSpeech(context) {
) if (it == TextToSpeech.SUCCESS) {
return this tts?.let {
} it.language = defLanguage
it.setPitch(defaultPitch)
it.setSpeechRate(defaultSpeed)
it.setListener(
onStart = { onStartListener?.invoke() },
onError = { e -> e?.let { error -> onErrorListener?.invoke(error) } },
onRange = { start, end ->
if (this@TextToSpeechEngine.message != null) {
onHighlightListener?.invoke(start, end)
}
},
onDone = { onStartListener?.invoke() },
)
speak(message)
}
} else {
onErrorListener?.invoke(getErrorText(it))
}
}
}
fun setPitchAndSpeed( private fun speak(message: String): TextToSpeechEngine {
pitch: Float, tts?.speak(
speed: Float, message,
) { TextToSpeech.QUEUE_FLUSH,
defaultPitch = pitch null,
defaultSpeed = speed TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED,
} )
return this
}
fun resetPitchAndSpeed() { fun setPitchAndSpeed(
defaultPitch = DEF_SPEECH_AND_PITCH pitch: Float,
defaultSpeed = DEF_SPEECH_AND_PITCH speed: Float,
} ) {
defaultPitch = pitch
defaultSpeed = speed
}
fun setLanguage(local: Locale): TextToSpeechEngine { fun resetPitchAndSpeed() {
this.defLanguage = local defaultPitch = DEF_SPEECH_AND_PITCH
return this defaultSpeed = DEF_SPEECH_AND_PITCH
} }
fun setHighlightedMessage(message: String) { fun setLanguage(local: Locale): TextToSpeechEngine {
this.message = message this.defLanguage = local
} return this
}
fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine { fun setHighlightedMessage(message: String) {
this.onStartListener = onStartListener this.message = message
return this }
}
fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine { fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine {
this.onDoneListener = onDoneListener this.onStartListener = onStartListener
return this return this
} }
fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine { fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine {
this.onErrorListener = onErrorListener this.onDoneListener = onDoneListener
return this return this
} }
fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine { fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine {
this.onHighlightListener = onHighlightListener this.onErrorListener = onErrorListener
return this return this
} }
fun destroy() { fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine {
tts?.stop() this.onHighlightListener = onHighlightListener
tts?.shutdown() return this
tts = null }
instance = null
} fun destroy() {
tts?.stop()
tts?.shutdown()
tts = null
instance = null
}
} }
inline fun TextToSpeech.setListener( inline fun TextToSpeech.setListener(
crossinline onStart: (String?) -> Unit = {}, crossinline onStart: (String?) -> Unit = {},
crossinline onError: (String?) -> Unit = {}, crossinline onError: (String?) -> Unit = {},
crossinline onRange: (Int, Int) -> Unit = { _, _ -> }, crossinline onRange: (Int, Int) -> Unit = { _, _ -> },
crossinline onDone: (String?) -> Unit, crossinline onDone: (String?) -> Unit,
) = ) = this.apply {
this.apply {
setOnUtteranceProgressListener( setOnUtteranceProgressListener(
object : UtteranceProgressListener() { object : UtteranceProgressListener() {
override fun onStart(p0: String?) { override fun onStart(p0: String?) {
onStart.invoke(p0) onStart.invoke(p0)
} }
override fun onDone(p0: String?) { override fun onDone(p0: String?) {
onDone.invoke(p0) onDone.invoke(p0)
} }
@Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)")) @Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)"))
override fun onError(p0: String?) { override fun onError(p0: String?) {
onError.invoke(p0) onError.invoke(p0)
} }
override fun onRangeStart( override fun onRangeStart(
utteranceId: String?, utteranceId: String?,
start: Int, start: Int,
end: Int, end: Int,
frame: Int, frame: Int,
) { ) {
super.onRangeStart(utteranceId, start, end, frame) super.onRangeStart(utteranceId, start, end, frame)
onRange.invoke(start, end) onRange.invoke(start, end)
} }
}, },
) )
} }
@@ -29,149 +29,149 @@ import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
class TextToSpeechHelper private constructor(private val context: WeakReference<Context>) : class TextToSpeechHelper private constructor(private val context: WeakReference<Context>) :
LifecycleEventObserver { LifecycleEventObserver {
private val appContext private val appContext
get() = context.get()!!.applicationContext get() = context.get()!!.applicationContext
private var message: String? = null private var message: String? = null
private var ttsEngine: TextToSpeechEngine? = null private var ttsEngine: TextToSpeechEngine? = null
private var onStart: (() -> Unit)? = null private var onStart: (() -> Unit)? = null
private var onDoneListener: (() -> Unit)? = null private var onDoneListener: (() -> Unit)? = null
private var onErrorListener: ((String) -> Unit)? = null private var onErrorListener: ((String) -> Unit)? = null
private var onHighlightListener: ((Pair<Int, Int>) -> Unit)? = null private var onHighlightListener: ((Pair<Int, Int>) -> Unit)? = null
private var customActionForDestroy: (() -> Unit)? = null private var customActionForDestroy: (() -> Unit)? = null
init { init {
Log.d("Init", "Init TTS") Log.d("Init", "Init TTS")
initTTS() initTTS()
}
fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper {
owner.lifecycle.addObserver(this)
return this
}
private fun initTTS() =
context.get()?.run {
ttsEngine =
TextToSpeechEngine.getInstance()
.setOnCompletionListener { onDoneListener?.invoke() }
.setOnErrorListener { onErrorListener?.invoke(it) }
.setOnStartListener { onStart?.invoke() }
}
fun speak(message: String): TextToSpeechHelper {
if (ttsEngine == null) {
initTTS()
}
this.message = message
ttsEngine?.initTTS(
appContext,
message,
)
return this
}
/**
* This method will highlight the text in the textView
*
* @exception Exception("Message can't be null for highlighting !! Call speak() first")
*/
fun highlight(): TextToSpeechHelper {
if (message == null) {
throw Exception("Message can't be null for highlighting !! Call speak() first")
}
ttsEngine?.setHighlightedMessage(message!!)
ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) }
return this
}
fun removeHighlight(): TextToSpeechHelper {
message = null
onHighlightListener = null
return this
}
fun destroy(action: (() -> Unit) = {}) {
ttsEngine?.destroy()
ttsEngine = null
action.invoke()
instance = null
}
fun onStart(onStartListener: () -> Unit): TextToSpeechHelper {
this.onStart = onStartListener
return this
}
fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper {
this.onDoneListener = onCompleteListener
return this
}
fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper {
this.onErrorListener = onErrorListener
return this
}
fun onHighlight(onHighlightListener: (Pair<Int, Int>) -> Unit): TextToSpeechHelper {
this.onHighlightListener = onHighlightListener
return this
}
fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper {
customActionForDestroy = action
return this
}
fun setLanguage(locale: Locale): TextToSpeechHelper {
ttsEngine?.setLanguage(locale)
return this
}
fun setPitchAndSpeed(
pitch: Float = DEF_SPEECH_AND_PITCH,
speed: Float = DEF_SPEECH_AND_PITCH,
): TextToSpeechHelper {
ttsEngine?.setPitchAndSpeed(pitch, speed)
return this
}
fun resetPitchAndSpeed(): TextToSpeechHelper {
ttsEngine?.resetPitchAndSpeed()
return this
}
companion object {
private var instance: TextToSpeechHelper? = null
fun getInstance(context: Context): TextToSpeechHelper {
synchronized(TextToSpeechHelper::class.java) {
if (instance == null) {
instance = TextToSpeechHelper(WeakReference(context))
} }
return instance!!
}
}
}
override fun onStateChanged( fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper {
source: LifecycleOwner, owner.lifecycle.addObserver(this)
event: Lifecycle.Event, return this
) { }
if (
event == Lifecycle.Event.ON_DESTROY || private fun initTTS() =
event == Lifecycle.Event.ON_STOP || context.get()?.run {
event == Lifecycle.Event.ON_PAUSE ttsEngine =
) { TextToSpeechEngine.getInstance()
destroy { customActionForDestroy?.invoke() } .setOnCompletionListener { onDoneListener?.invoke() }
.setOnErrorListener { onErrorListener?.invoke(it) }
.setOnStartListener { onStart?.invoke() }
}
fun speak(message: String): TextToSpeechHelper {
if (ttsEngine == null) {
initTTS()
}
this.message = message
ttsEngine?.initTTS(
appContext,
message,
)
return this
}
/**
* This method will highlight the text in the textView
*
* @exception Exception("Message can't be null for highlighting !! Call speak() first")
*/
fun highlight(): TextToSpeechHelper {
if (message == null) {
throw Exception("Message can't be null for highlighting !! Call speak() first")
}
ttsEngine?.setHighlightedMessage(message!!)
ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) }
return this
}
fun removeHighlight(): TextToSpeechHelper {
message = null
onHighlightListener = null
return this
}
fun destroy(action: (() -> Unit) = {}) {
ttsEngine?.destroy()
ttsEngine = null
action.invoke()
instance = null
}
fun onStart(onStartListener: () -> Unit): TextToSpeechHelper {
this.onStart = onStartListener
return this
}
fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper {
this.onDoneListener = onCompleteListener
return this
}
fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper {
this.onErrorListener = onErrorListener
return this
}
fun onHighlight(onHighlightListener: (Pair<Int, Int>) -> Unit): TextToSpeechHelper {
this.onHighlightListener = onHighlightListener
return this
}
fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper {
customActionForDestroy = action
return this
}
fun setLanguage(locale: Locale): TextToSpeechHelper {
ttsEngine?.setLanguage(locale)
return this
}
fun setPitchAndSpeed(
pitch: Float = DEF_SPEECH_AND_PITCH,
speed: Float = DEF_SPEECH_AND_PITCH,
): TextToSpeechHelper {
ttsEngine?.setPitchAndSpeed(pitch, speed)
return this
}
fun resetPitchAndSpeed(): TextToSpeechHelper {
ttsEngine?.resetPitchAndSpeed()
return this
}
companion object {
private var instance: TextToSpeechHelper? = null
fun getInstance(context: Context): TextToSpeechHelper {
synchronized(TextToSpeechHelper::class.java) {
if (instance == null) {
instance = TextToSpeechHelper(WeakReference(context))
}
return instance!!
}
}
}
override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event,
) {
if (
event == Lifecycle.Event.ON_DESTROY ||
event == Lifecycle.Event.ON_STOP ||
event == Lifecycle.Event.ON_PAUSE
) {
destroy { customActionForDestroy?.invoke() }
}
}
} }
}
}
@@ -64,284 +64,284 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Timer
import kotlin.concurrent.schedule
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Timer
import kotlin.concurrent.schedule
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val isOnMobileDataState = mutableStateOf(false) private val isOnMobileDataState = mutableStateOf(false)
private val isOnWifiDataState = mutableStateOf(false) private val isOnWifiDataState = mutableStateOf(false)
// Service Manager is only active when the activity is active. // Service Manager is only active when the activity is active.
val serviceManager = ServiceManager() val serviceManager = ServiceManager()
private var shouldPauseService = true private var shouldPauseService = true
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d("Lifetime Event", "MainActivity.onCreate") Log.d("Lifetime Event", "MainActivity.onCreate")
setContent { setContent {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(this) val displayFeatures = calculateDisplayFeatures(this)
val windowSizeClass = calculateWindowSizeClass(this) val windowSizeClass = calculateWindowSizeClass(this)
LaunchedEffect(key1 = sharedPreferencesViewModel) { LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init() sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
} }
LaunchedEffect(isOnMobileDataState) { LaunchedEffect(isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState) sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
} }
AmethystTheme(sharedPreferencesViewModel) { AmethystTheme(sharedPreferencesViewModel) {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
val accountStateViewModel: AccountStateViewModel = viewModel() val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() } LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() }
AccountScreen(accountStateViewModel, sharedPreferencesViewModel) AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
} }
}
}
}
fun prepareToLaunchSigner() {
shouldPauseService = false
}
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
Log.d("Lifetime Event", "MainActivity.onResume")
// starts muted every time
DEFAULT_MUTED_SETTING.value = true
// Keep connection alive if it's calling the signer app
Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() }
} }
GlobalScope.launch(Dispatchers.IO) { fun prepareToLaunchSigner() {
PushNotificationUtils.init(LocalPreferences.allSavedAccounts()) shouldPauseService = false
} }
val connectivityManager = @OptIn(DelicateCoroutinesApi::class)
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager) override fun onResume() {
connectivityManager.registerDefaultNetworkCallback(networkCallback) super.onResume()
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
updateNetworkCapabilities(it)
}
// resets state until next External Signer Call Log.d("Lifetime Event", "MainActivity.onResume")
Timer().schedule(350) { shouldPauseService = true }
}
override fun onPause() { // starts muted every time
Log.d("Lifetime Event", "MainActivity.onPause") DEFAULT_MUTED_SETTING.value = true
LanguageTranslatorService.clear() // Keep connection alive if it's calling the signer app
serviceManager.cleanObservers() Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
if (shouldPauseService) {
// if (BuildConfig.DEBUG) { GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() }
GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) }
// }
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() }
}
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
.unregisterNetworkCallback(networkCallback)
super.onPause()
}
override fun onStart() {
super.onStart()
Log.d("Lifetime Event", "MainActivity.onStart")
}
override fun onStop() {
super.onStop()
// Graph doesn't completely clear.
// GlobalScope.launch(Dispatchers.Default) {
// serviceManager.trimMemory()
// }
Log.d("Lifetime Event", "MainActivity.onStop")
}
override fun onDestroy() {
Log.d("Lifetime Event", "MainActivity.onDestroy")
GlobalScope.launch(Dispatchers.Main) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
}
super.onDestroy()
}
/**
* Release memory when the UI becomes hidden or when system resources become low.
*
* @param level the memory-related event that was raised.
*/
@OptIn(DelicateCoroutinesApi::class)
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
}
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
var changedNetwork = false
if (isOnMobileDataState.value != isOnMobileData) {
isOnMobileDataState.value = isOnMobileData
changedNetwork = true
}
if (isOnWifiDataState.value != isOnWifi) {
isOnWifiDataState.value = isOnWifi
changedNetwork = true
}
if (changedNetwork) {
if (isOnMobileData) {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
}
}
return changedNetwork
}
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
var lastNetwork: Network? = null
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() }
} }
lastNetwork = network
}
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
Log.d( PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
"ServiceManager NetworkCallback",
"onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}",
)
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
serviceManager.forceRestart()
}
} }
}
val connectivityManager =
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
connectivityManager.registerDefaultNetworkCallback(networkCallback)
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
updateNetworkCapabilities(it)
}
// resets state until next External Signer Call
Timer().schedule(350) { shouldPauseService = true }
} }
override fun onPause() {
Log.d("Lifetime Event", "MainActivity.onPause")
LanguageTranslatorService.clear()
serviceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) }
// }
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() }
}
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
.unregisterNetworkCallback(networkCallback)
super.onPause()
}
override fun onStart() {
super.onStart()
Log.d("Lifetime Event", "MainActivity.onStart")
}
override fun onStop() {
super.onStop()
// Graph doesn't completely clear.
// GlobalScope.launch(Dispatchers.Default) {
// serviceManager.trimMemory()
// }
Log.d("Lifetime Event", "MainActivity.onStop")
}
override fun onDestroy() {
Log.d("Lifetime Event", "MainActivity.onDestroy")
GlobalScope.launch(Dispatchers.Main) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
}
super.onDestroy()
}
/**
* Release memory when the UI becomes hidden or when system resources become low.
*
* @param level the memory-related event that was raised.
*/
@OptIn(DelicateCoroutinesApi::class)
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
}
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
var changedNetwork = false
if (isOnMobileDataState.value != isOnMobileData) {
isOnMobileDataState.value = isOnMobileData
changedNetwork = true
}
if (isOnWifiDataState.value != isOnWifi) {
isOnWifiDataState.value = isOnWifi
changedNetwork = true
}
if (changedNetwork) {
if (isOnMobileData) {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
}
}
return changedNetwork
}
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
var lastNetwork: Network? = null
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() }
}
lastNetwork = network
}
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) {
Log.d(
"ServiceManager NetworkCallback",
"onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}",
)
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
serviceManager.forceRestart()
}
}
}
}
} }
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() { class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
@SuppressLint("MissingSuperCall") @SuppressLint("MissingSuperCall")
override fun createIntent( override fun createIntent(
context: Context, context: Context,
input: String, input: String,
): Intent { ): Intent {
// Force only images and videos to be selectable // Force only images and videos to be selectable
// Force OPEN Document because of the resulting URI must be passed to the // Force OPEN Document because of the resulting URI must be passed to the
// Playback service and the picker's permissions only allow the activity to read the URI // Playback service and the picker's permissions only allow the activity to read the URI
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
// Force only images and videos to be selectable // Force only images and videos to be selectable
type = "*/*" type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
} }
}
} }
fun uriToRoute(uri: String?): String? { fun uriToRoute(uri: String?): String? {
return if (uri.equals("nostr:Notifications", true)) { return if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true") Route.Notification.route.replace("{scrollToTop}", "true")
} else {
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
} else { } else {
val nip19 = Nip19.uriToRoute(uri) if (uri?.startsWith("nostr:Hashtag?id=") == true) {
when (nip19?.type) { Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
Nip19.Type.USER -> "User/${nip19.hex}" } else {
Nip19.Type.NOTE -> "Note/${nip19.hex}" val nip19 = Nip19.uriToRoute(uri)
Nip19.Type.EVENT -> { when (nip19?.type) {
if (nip19.kind == PrivateDmEvent.KIND) { Nip19.Type.USER -> "User/${nip19.hex}"
nip19.author?.let { "RoomByAuthor/$it" } Nip19.Type.NOTE -> "Note/${nip19.hex}"
} else if ( Nip19.Type.EVENT -> {
nip19.kind == ChannelMessageEvent.KIND || if (nip19.kind == PrivateDmEvent.KIND) {
nip19.kind == ChannelCreateEvent.KIND || nip19.author?.let { "RoomByAuthor/$it" }
nip19.kind == ChannelMetadataEvent.KIND } else if (
) { nip19.kind == ChannelMessageEvent.KIND ||
"Channel/${nip19.hex}" nip19.kind == ChannelCreateEvent.KIND ||
} else { nip19.kind == ChannelMetadataEvent.KIND
"Event/${nip19.hex}" ) {
} "Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS ->
if (nip19.kind == CommunityDefinitionEvent.KIND) {
"Community/${nip19.hex}"
} else if (nip19.kind == LiveActivitiesEvent.KIND) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
else -> null
}
} }
Nip19.Type.ADDRESS -> ?: try {
if (nip19.kind == CommunityDefinitionEvent.KIND) { uri?.let {
"Community/${nip19.hex}" Nip47WalletConnectParser.parse(it)
} else if (nip19.kind == LiveActivitiesEvent.KIND) { val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
"Channel/${nip19.hex}" Route.Home.base + "?nip47=" + encodedUri
} else { }
"Event/${nip19.hex}" } catch (e: Exception) {
} null
else -> null }
}
} }
?: try {
uri?.let {
Nip47WalletConnectParser.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
null
}
}
} }
@@ -20,49 +20,49 @@
*/ */
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import kotlinx.coroutines.delay
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlinx.coroutines.delay
class ImageDownloader { class ImageDownloader {
suspend fun waitAndGetImage(imageUrl: String): ByteArray? { suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
var imageData: ByteArray? = null var imageData: ByteArray? = null
var tentatives = 0 var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds. // Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) { while (imageData == null && tentatives < 15) {
imageData = imageData =
try { try {
HttpURLConnection.setFollowRedirects(true) HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl) var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection var huc = url.openConnection() as HttpURLConnection
huc.instanceFollowRedirects = true huc.instanceFollowRedirects = true
var responseCode = huc.responseCode var responseCode = huc.responseCode
if (responseCode in 300..400) { if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location") val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again // open the new connnection again
url = URL(newUrl) url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection huc = url.openConnection() as HttpURLConnection
responseCode = huc.responseCode responseCode = huc.responseCode
} }
if (responseCode in 200..300) { if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() } huc.inputStream.use { it.readBytes() }
} else { } else {
tentatives++ tentatives++
delay(1000) delay(1000)
null null
} }
} catch (e: Exception) { } catch (e: Exception) {
tentatives++ tentatives++
delay(1000) delay(1000)
null null
}
} }
}
return imageData return imageData
} }
} }
@@ -31,8 +31,6 @@ import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import java.io.File
import java.util.UUID
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
@@ -42,168 +40,170 @@ import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
import java.io.File
import java.util.UUID
object ImageSaver { object ImageSaver {
/** /**
* Saves the image to the gallery. May require a storage permission. * Saves the image to the gallery. May require a storage permission.
* *
* @see PICTURES_SUBDIRECTORY * @see PICTURES_SUBDIRECTORY
*/ */
fun saveImage( fun saveImage(
url: String, url: String,
context: Context, context: Context,
onSuccess: () -> Any?, onSuccess: () -> Any?,
onError: (Throwable) -> Any?, onError: (Throwable) -> Any?,
) { ) {
val client = HttpClient.getHttpClient() val client = HttpClient.getHttpClient()
val request = val request =
Request.Builder() Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.get() .get()
.url(url) .url(url)
.build() .build()
client client
.newCall(request) .newCall(request)
.enqueue( .enqueue(
object : Callback { object : Callback {
override fun onFailure( override fun onFailure(
call: Call, call: Call,
e: IOException, e: IOException,
) { ) {
e.printStackTrace()
onError(e)
}
override fun onResponse(
call: Call,
response: Response,
) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) { "Can't find out the content type" }
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
},
)
}
fun saveImage(
localFile: File,
mimeType: String?,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
try {
val extension =
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(
displayName = UUID.randomUUID().toString(),
contentType = mimeType ?: "",
contentSource = buffer,
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = UUID.randomUUID().toString() + ".$extension",
contentSource = buffer,
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
onError(e) onError(e)
} }
}
override fun onResponse( @RequiresApi(Build.VERSION_CODES.Q)
call: Call, private fun saveContentQ(
response: Response, displayName: String,
) { contentType: String,
try { contentSource: BufferedSource,
check(response.isSuccessful) contentResolver: ContentResolver,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues =
val contentType = response.header("Content-Type") ContentValues().apply {
checkNotNull(contentType) { "Can't find out the content type" } put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
saveContentQ( put(
displayName = File(url).nameWithoutExtension, MediaStore.MediaColumns.RELATIVE_PATH,
contentType = contentType, Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
) )
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
} }
}
},
)
}
fun saveImage( val masterUri =
localFile: File, if (contentType.startsWith("image")) {
mimeType: String?, MediaStore.Images.Media.EXTERNAL_CONTENT_URI
context: Context, } else {
onSuccess: () -> Any?, MediaStore.Video.Media.EXTERNAL_CONTENT_URI
onError: (Throwable) -> Any?, }
) {
try {
val extension =
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val uri = contentResolver.insert(masterUri, contentValues)
saveContentQ( checkNotNull(uri) { "Can't insert the new content" }
displayName = UUID.randomUUID().toString(),
contentType = mimeType ?: "",
contentSource = buffer,
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = UUID.randomUUID().toString() + ".$extension",
contentSource = buffer,
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
@RequiresApi(Build.VERSION_CODES.Q) try {
private fun saveContentQ( val outputStream = contentResolver.openOutputStream(uri)
displayName: String, checkNotNull(outputStream) { "Can't open the content output stream" }
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY,
)
}
val masterUri = outputStream.use { contentSource.readAll(it.sink()) }
if (contentType.startsWith("image")) { } catch (e: Exception) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI contentResolver.delete(uri, null, null)
} else { throw e
MediaStore.Video.Media.EXTERNAL_CONTENT_URI }
}
val uri = contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) { "Can't insert the new content" }
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) { "Can't open the content output stream" }
outputStream.use { contentSource.readAll(it.sink()) }
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY,
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
} }
val outputFile = File(subdirectory, fileName) private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY,
)
outputFile.outputStream().use { contentSource.readAll(it.sink()) } if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
// Call the media scanner manually, so the image val outputFile = File(subdirectory, fileName)
// appears in the gallery faster.
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst" outputFile.outputStream().use { contentSource.readAll(it.sink()) }
// Call the media scanner manually, so the image
// appears in the gallery faster.
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
} }
@@ -41,32 +41,32 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@Composable @Composable
fun InformationDialog( fun InformationDialog(
title: String, title: String,
textContent: String, textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(), buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(title) }, title = { Text(title) },
text = { SelectionContainer { Text(textContent) } }, text = { SelectionContainer { Text(textContent) } },
confirmButton = { confirmButton = {
Button( Button(
onClick = onDismiss, onClick = onDismiss,
colors = buttonColors, colors = buttonColors,
contentPadding = PaddingValues(horizontal = Size16dp), contentPadding = PaddingValues(horizontal = Size16dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Done, imageVector = Icons.Outlined.Done,
contentDescription = null, contentDescription = null,
) )
Spacer(StdHorzSpacer) Spacer(StdHorzSpacer)
Text(stringResource(R.string.error_dialog_button_ok)) Text(stringResource(R.string.error_dialog_button_ok))
} }
} }
}, },
) )
} }
@@ -101,338 +101,340 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun JoinUserOrChannelView( fun JoinUserOrChannelView(
onClose: () -> Unit, onClose: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
val searchBarViewModel: SearchBarViewModel = val searchBarViewModel: SearchBarViewModel =
viewModel( viewModel(
key = "SearchBarViewModel", key = "SearchBarViewModel",
factory = factory =
SearchBarViewModel.Factory( SearchBarViewModel.Factory(
accountViewModel.account, accountViewModel.account,
), ),
) )
JoinUserOrChannelView( JoinUserOrChannelView(
searchBarViewModel = searchBarViewModel, searchBarViewModel = searchBarViewModel,
onClose = onClose, onClose = onClose,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
} }
@Composable @Composable
fun JoinUserOrChannelView( fun JoinUserOrChannelView(
searchBarViewModel: SearchBarViewModel, searchBarViewModel: SearchBarViewModel,
onClose: () -> Unit, onClose: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
Dialog( Dialog(
onDismissRequest = { onDismissRequest = {
NostrSearchEventOrUserDataSource.clear() NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clear() searchBarViewModel.clear()
onClose() onClose()
}, },
properties = properties =
DialogProperties( DialogProperties(
dismissOnClickOutside = false, dismissOnClickOutside = false,
), ),
) { ) {
Surface { Surface {
Column( Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp), modifier = Modifier.padding(10.dp).heightIn(min = 500.dp),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CloseButton( CloseButton(
onPress = { onPress = {
searchBarViewModel.clear() searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear() NostrSearchEventOrUserDataSource.clear()
onClose() onClose()
}, },
) )
Text( Text(
text = stringResource(R.string.channel_list_join_conversation), text = stringResource(R.string.channel_list_join_conversation),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = "", text = "",
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
}
Spacer(modifier = Modifier.height(15.dp))
RenderSearch(searchBarViewModel, accountViewModel, nav)
}
} }
Spacer(modifier = Modifier.height(15.dp))
RenderSearch(searchBarViewModel, accountViewModel, nav)
}
} }
}
} }
@Composable @Composable
private fun RenderSearch( private fun RenderSearch(
searchBarViewModel: SearchBarViewModel, searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries. // Create a channel for processing search queries.
val searchTextChanges = remember { Channel<String>(Channel.CONFLATED) } val searchTextChanges = remember { Channel<String>(Channel.CONFLATED) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { LocalCache.live.newEventBundles.collect {
checkNotInMainThread() checkNotInMainThread()
if (searchBarViewModel.isSearchingFun()) { if (searchBarViewModel.isSearchingFun()) {
searchBarViewModel.invalidateData() searchBarViewModel.invalidateData()
} }
} }
}
}
LaunchedEffect(Unit) {
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges
.receiveAsFlow()
.filter { it.isNotBlank() }
.distinctUntilChanged()
.debounce(300)
.collectLatest {
if (it.length >= 2) {
NostrSearchEventOrUserDataSource.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
} }
} }
}
DisposableEffect(lifeCycleOwner) { LaunchedEffect(Unit) {
val observer = LifecycleEventObserver { _, event -> // Wait for text changes to stop for 300 ms before firing off search.
if (event == Lifecycle.Event.ON_RESUME) { withContext(Dispatchers.IO) {
println("Join Start") searchTextChanges
NostrSearchEventOrUserDataSource.start() .receiveAsFlow()
searchBarViewModel.invalidateData() .filter { it.isNotBlank() }
} .distinctUntilChanged()
if (event == Lifecycle.Event.ON_PAUSE) { .debounce(300)
println("Join Stop") .collectLatest {
NostrSearchEventOrUserDataSource.clear() if (it.length >= 2) {
NostrSearchEventOrUserDataSource.stop() NostrSearchEventOrUserDataSource.search(it.trim())
} }
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
}
}
} }
lifeCycleOwner.lifecycle.addObserver(observer) DisposableEffect(lifeCycleOwner) {
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } val observer =
} LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Join Start")
NostrSearchEventOrUserDataSource.start()
searchBarViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Join Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
}
}
// LAST ROW lifeCycleOwner.lifecycle.addObserver(observer)
SearchEditTextForJoin(searchBarViewModel, searchTextChanges) onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) // LAST ROW
SearchEditTextForJoin(searchBarViewModel, searchTextChanges)
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav)
} }
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun SearchEditTextForJoin( private fun SearchEditTextForJoin(
searchBarViewModel: SearchBarViewModel, searchBarViewModel: SearchBarViewModel,
searchTextChanges: Channel<String>, searchTextChanges: Channel<String>,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// initialize focus reference to be able to request focus programmatically // initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
delay(100) delay(100)
focusRequester.requestFocus() focusRequester.requestFocus()
}
}
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
value = searchBarViewModel.searchValue,
onValueChange = {
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) }
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) },
modifier =
Modifier.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
},
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear),
)
}
} }
}, }
)
} Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
value = searchBarViewModel.searchValue,
onValueChange = {
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) }
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) },
modifier =
Modifier.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
},
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear),
)
}
}
},
)
}
} }
@Composable @Composable
private fun RenderSearchResults( private fun RenderSearchResults(
searchBarViewModel: SearchBarViewModel, searchBarViewModel: SearchBarViewModel,
listState: LazyListState, listState: LazyListState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
if (searchBarViewModel.isSearching) { if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle()
val automaticallyShowProfilePicture = remember { val automaticallyShowProfilePicture =
accountViewModel.settings.showProfilePictures.value remember {
} accountViewModel.settings.showProfilePictures.value
}
Row( Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp), modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp),
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
contentPadding = FeedPadding, contentPadding = FeedPadding,
state = listState, state = listState,
) { ) {
itemsIndexed( itemsIndexed(
users, users,
key = { _, item -> "u" + item.pubkeyHex }, key = { _, item -> "u" + item.pubkeyHex },
) { _, item -> ) { _, item ->
UserComposeForChat(item, accountViewModel) { UserComposeForChat(item, accountViewModel) {
accountViewModel.createChatRoomFor(item) { nav("Room/$it") } accountViewModel.createChatRoomFor(item) { nav("Room/$it") }
searchBarViewModel.clear() searchBarViewModel.clear()
} }
}
itemsIndexed(
channels,
key = { _, item -> "c" + item.idHex },
) { _, item ->
RenderChannel(item, automaticallyShowProfilePicture) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
}
}
} }
itemsIndexed(
channels,
key = { _, item -> "c" + item.idHex },
) { _, item ->
RenderChannel(item, automaticallyShowProfilePicture) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
}
}
} }
}
} }
@Composable @Composable
private fun RenderChannel( private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel, item: com.vitorpamplona.amethyst.model.Channel,
loadProfilePicture: Boolean, loadProfilePicture: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val hasNewMessages = remember { mutableStateOf(false) } val hasNewMessages = remember { mutableStateOf(false) }
ChannelName( ChannelName(
channelIdHex = item.idHex, channelIdHex = item.idHex,
channelPicture = item.profilePicture(), channelPicture = item.profilePicture(),
channelTitle = { channelTitle = {
Text( Text(
item.toBestDisplayName(), item.toBestDisplayName(),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
}, },
channelLastTime = null, channelLastTime = null,
channelLastContent = item.summary(), channelLastContent = item.summary(),
hasNewMessages, hasNewMessages,
onClick = onClick, onClick = onClick,
loadProfilePicture = loadProfilePicture, loadProfilePicture = loadProfilePicture,
) )
} }
@Composable @Composable
fun UserComposeForChat( fun UserComposeForChat(
baseUser: User, baseUser: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Column( Column(
modifier = modifier =
Modifier.clickable( Modifier.clickable(
onClick = onClick, onClick = onClick,
), ),
) {
Row(
modifier =
Modifier.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) { ) {
ClickableUserPicture(baseUser, Size55dp, accountViewModel) Row(
modifier =
Modifier.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column( Column(
modifier = Modifier.padding(start = 10.dp).weight(1f), modifier = Modifier.padding(start = 10.dp).weight(1f),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
DisplayUserAboutInfo(baseUser) DisplayUserAboutInfo(baseUser)
} }
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
} }
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
}
} }
@Composable @Composable
private fun DisplayUserAboutInfo(baseUser: User) { private fun DisplayUserAboutInfo(baseUser: User) {
val baseUserState by baseUser.live().metadata.observeAsState() val baseUserState by baseUser.live().metadata.observeAsState()
val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } } val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } }
Text( Text(
text = about, text = about,
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
@@ -52,101 +52,101 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable @Composable
fun NewChannelView( fun NewChannelView(
onClose: () -> Unit, onClose: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
channel: PublicChatChannel? = null, channel: PublicChatChannel? = null,
) { ) {
val postViewModel: NewChannelViewModel = viewModel() val postViewModel: NewChannelViewModel = viewModel()
postViewModel.load(accountViewModel.account, channel) postViewModel.load(accountViewModel.account, channel)
Dialog( Dialog(
onDismissRequest = { onClose() }, onDismissRequest = { onClose() },
properties = properties =
DialogProperties( DialogProperties(
dismissOnClickOutside = false, dismissOnClickOutside = false,
), ),
) { ) {
Surface { Surface {
Column( Column(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CloseButton( CloseButton(
onPress = { onPress = {
postViewModel.clear() postViewModel.clear()
onClose() onClose()
}, },
) )
PostButton( PostButton(
onPost = { onPost = {
postViewModel.create() postViewModel.create()
onClose() onClose()
}, },
postViewModel.channelName.value.text.isNotBlank(), postViewModel.channelName.value.text.isNotBlank(),
) )
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_awesome_group),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = stringResource(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
}
} }
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_awesome_group),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = stringResource(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
}
} }
}
} }

Some files were not shown because too many files have changed in this diff Show More