Reverts to the non-Google kotlin style.
This commit is contained in:
@@ -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
+75
-75
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,5 +21,5 @@
|
|||||||
package com.vitorpamplona.amethyst.service.lang
|
package com.vitorpamplona.amethyst.service.lang
|
||||||
|
|
||||||
object LanguageTranslatorService {
|
object LanguageTranslatorService {
|
||||||
fun clear() {}
|
fun clear() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-59
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-77
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -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.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-122
@@ -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])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-10
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-111
@@ -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
|
||||||
|
|||||||
+33
-34
@@ -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()
|
||||||
|
|||||||
+109
-110
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-117
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+376
-375
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-43
@@ -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,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-107
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-79
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-195
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-132
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-231
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-191
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-195
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-77
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-119
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-35
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-138
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-120
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+271
-269
@@ -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
Reference in New Issue
Block a user