|
|
|
@@ -0,0 +1,862 @@
|
|
|
|
|
package social.flotilla.notifications
|
|
|
|
|
|
|
|
|
|
import android.Manifest
|
|
|
|
|
import android.app.NotificationChannel
|
|
|
|
|
import android.app.NotificationManager
|
|
|
|
|
import android.util.Log
|
|
|
|
|
import android.app.PendingIntent
|
|
|
|
|
import android.content.Context
|
|
|
|
|
import android.content.Intent
|
|
|
|
|
import android.app.ActivityManager
|
|
|
|
|
import android.content.SharedPreferences
|
|
|
|
|
import android.content.pm.PackageManager
|
|
|
|
|
import android.database.Cursor
|
|
|
|
|
import android.net.Uri
|
|
|
|
|
import android.os.Build
|
|
|
|
|
import androidx.core.app.NotificationCompat
|
|
|
|
|
import androidx.core.app.NotificationManagerCompat
|
|
|
|
|
import androidx.core.content.ContextCompat
|
|
|
|
|
import androidx.work.Worker
|
|
|
|
|
import androidx.work.WorkerParameters
|
|
|
|
|
import fr.acinq.secp256k1.Secp256k1
|
|
|
|
|
import okhttp3.OkHttpClient
|
|
|
|
|
import okhttp3.Request
|
|
|
|
|
import okhttp3.Response
|
|
|
|
|
import okhttp3.WebSocket
|
|
|
|
|
import okhttp3.WebSocketListener
|
|
|
|
|
import org.json.JSONArray
|
|
|
|
|
import org.json.JSONObject
|
|
|
|
|
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.KEY_STATE
|
|
|
|
|
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.PREFS_NAME
|
|
|
|
|
import java.nio.charset.StandardCharsets
|
|
|
|
|
import java.security.MessageDigest
|
|
|
|
|
import java.security.SecureRandom
|
|
|
|
|
import java.util.Arrays
|
|
|
|
|
import java.util.UUID
|
|
|
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
|
|
|
import java.util.concurrent.CountDownLatch
|
|
|
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
|
import android.util.Base64
|
|
|
|
|
|
|
|
|
|
class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
|
|
|
|
companion object {
|
|
|
|
|
private const val TAG = "PushFallback"
|
|
|
|
|
private const val CHANNEL_ID = "flotilla_fallback"
|
|
|
|
|
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
|
|
|
|
private const val SOCKET_TIMEOUT_SECONDS = 20L
|
|
|
|
|
private const val REJECTED = "__REJECTED__"
|
|
|
|
|
private const val KIND_RELAY_AUTH = 22242
|
|
|
|
|
private const val KIND_NIP46_RPC = 24133
|
|
|
|
|
private val SECP = Secp256k1.get()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val prefs: SharedPreferences =
|
|
|
|
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
|
|
|
private val client: OkHttpClient = OkHttpClient.Builder().build()
|
|
|
|
|
|
|
|
|
|
// ---- Socket pool ----
|
|
|
|
|
|
|
|
|
|
// Opens each relay URL at most once; caller must invoke closeAll() when done.
|
|
|
|
|
private inner class SocketPool {
|
|
|
|
|
private val sockets = ConcurrentHashMap<String, WebSocket>()
|
|
|
|
|
|
|
|
|
|
fun open(url: String, listener: WebSocketListener): WebSocket =
|
|
|
|
|
sockets.getOrPut(url) {
|
|
|
|
|
client.newWebSocket(Request.Builder().url(url).build(), listener)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun closeAll() {
|
|
|
|
|
for ((_, ws) in sockets) ws.close(1000, "done")
|
|
|
|
|
sockets.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun doWork(): Result {
|
|
|
|
|
if (isAppInForeground()) {
|
|
|
|
|
return Result.success()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val pool = SocketPool()
|
|
|
|
|
try {
|
|
|
|
|
val rawState = prefs.getString(KEY_STATE, "") ?: ""
|
|
|
|
|
if (rawState.isEmpty()) return Result.success()
|
|
|
|
|
|
|
|
|
|
val state = JSONObject(rawState)
|
|
|
|
|
val sessionInfo = getSessionInfo(state)
|
|
|
|
|
val subscriptions = parseSubscriptions(state)
|
|
|
|
|
if (subscriptions.isEmpty()) return Result.success()
|
|
|
|
|
|
|
|
|
|
val activeSince = state.optLong("activeSince", 0L)
|
|
|
|
|
val seen = mutableSetOf<String>()
|
|
|
|
|
var latestPair: Pair<String, JSONObject>? = null
|
|
|
|
|
|
|
|
|
|
for (sub in subscriptions) {
|
|
|
|
|
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
|
|
|
|
val since = maxOf(prefs.getLong(cursorKey, 0L), activeSince)
|
|
|
|
|
val result = pollRelay(sub, since, sessionInfo, pool)
|
|
|
|
|
|
|
|
|
|
if (result.lastCursor > prefs.getLong(cursorKey, 0L)) {
|
|
|
|
|
prefs.edit().putLong(cursorKey, result.lastCursor).apply()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (event in result.events) {
|
|
|
|
|
val id = event.optString("id", "")
|
|
|
|
|
if (id.isNotEmpty() && seen.add(id)) {
|
|
|
|
|
val createdAt = event.optLong("created_at", 0L)
|
|
|
|
|
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
|
|
|
|
|
latestPair = Pair(sub.relay, event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (latestPair != null) {
|
|
|
|
|
val (relay, event) = latestPair!!
|
|
|
|
|
postNotification(relay, event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Result.success()
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
Log.e(TAG, "Worker failed", e)
|
|
|
|
|
return Result.success()
|
|
|
|
|
} finally {
|
|
|
|
|
pool.closeAll()
|
|
|
|
|
client.dispatcher.executorService.shutdown()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun isAppInForeground(): Boolean {
|
|
|
|
|
val am = applicationContext.getSystemService(ActivityManager::class.java) ?: return false
|
|
|
|
|
val tasks = am.getRunningAppProcesses() ?: return false
|
|
|
|
|
val pkg = applicationContext.packageName
|
|
|
|
|
return tasks.any { it.processName == pkg && it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun getSessionInfo(state: JSONObject): SessionInfo {
|
|
|
|
|
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
|
|
|
|
|
return SessionInfo(
|
|
|
|
|
session.optString("method", "anonymous"),
|
|
|
|
|
session.optString("pubkey", ""),
|
|
|
|
|
session,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun parseSubscriptions(state: JSONObject): List<Subscription> {
|
|
|
|
|
val result = mutableListOf<Subscription>()
|
|
|
|
|
val arr = state.optJSONArray("subscriptions") ?: return result
|
|
|
|
|
|
|
|
|
|
for (i in 0 until arr.length()) {
|
|
|
|
|
val item = arr.optJSONObject(i) ?: continue
|
|
|
|
|
val relay = item.optString("relay", "").trim()
|
|
|
|
|
|
|
|
|
|
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) continue
|
|
|
|
|
|
|
|
|
|
val filters = item.optJSONArray("filters")
|
|
|
|
|
if (filters == null || filters.length() == 0) continue
|
|
|
|
|
|
|
|
|
|
val key = item.optString("key", "").trim()
|
|
|
|
|
result.add(Subscription(relay, key, filters, item.optJSONArray("ignore")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun pollRelay(sub: Subscription, since: Long, sessionInfo: SessionInfo, pool: SocketPool): RelayResult {
|
|
|
|
|
val result = RelayResult()
|
|
|
|
|
val latch = CountDownLatch(1)
|
|
|
|
|
|
|
|
|
|
val listener = RelayListener(sub, since, sessionInfo, result, latch, pool)
|
|
|
|
|
pool.open(sub.relay, listener)
|
|
|
|
|
|
|
|
|
|
if (!latch.await(SOCKET_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
|
|
|
|
Log.d(TAG, "Relay ${sub.relay} timed out")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun postNotification(relay: String, event: JSONObject) {
|
|
|
|
|
val context = applicationContext
|
|
|
|
|
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
|
|
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
|
|
|
|
) return
|
|
|
|
|
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
|
|
|
val manager = context.getSystemService(NotificationManager::class.java)
|
|
|
|
|
if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
|
|
|
|
|
val channel = NotificationChannel(CHANNEL_ID, "Fallback Notifications", NotificationManager.IMPORTANCE_DEFAULT)
|
|
|
|
|
channel.description = "Notifications delivered by Android background fallback"
|
|
|
|
|
manager.createNotificationChannel(channel)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val id = event.optString("id", "")
|
|
|
|
|
val encodedRelay = Uri.encode(relay)
|
|
|
|
|
val url = "https://app.flotilla.social/?relay=$encodedRelay&id=$id"
|
|
|
|
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
|
|
|
|
intent.setPackage(context.packageName)
|
|
|
|
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
|
|
|
|
|
|
|
|
val pendingIntent = PendingIntent.getActivity(
|
|
|
|
|
context, 0, intent,
|
|
|
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
val body = "New activity"
|
|
|
|
|
|
|
|
|
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
|
|
|
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
|
|
|
|
.setContentTitle("Flotilla")
|
|
|
|
|
.setContentText(body)
|
|
|
|
|
.setAutoCancel(true)
|
|
|
|
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
|
|
|
.setContentIntent(pendingIntent)
|
|
|
|
|
.build()
|
|
|
|
|
|
|
|
|
|
NotificationManagerCompat.from(context).notify(1, notification)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
|
|
|
|
val kinds = filter.optJSONArray("kinds")
|
|
|
|
|
if (kinds != null && kinds.length() > 0) {
|
|
|
|
|
val kind = event.optInt("kind", -1)
|
|
|
|
|
var found = false
|
|
|
|
|
for (i in 0 until kinds.length()) {
|
|
|
|
|
if (kinds.optInt(i, -1) == kind) { found = true; break }
|
|
|
|
|
}
|
|
|
|
|
if (!found) return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val tags = event.optJSONArray("tags")
|
|
|
|
|
val iter = filter.keys()
|
|
|
|
|
while (iter.hasNext()) {
|
|
|
|
|
val key = iter.next()
|
|
|
|
|
if (!key.startsWith("#")) continue
|
|
|
|
|
val tagName = key.substring(1)
|
|
|
|
|
val allowed = filter.optJSONArray(key) ?: continue
|
|
|
|
|
if (allowed.length() == 0) continue
|
|
|
|
|
|
|
|
|
|
val allowedValues = mutableSetOf<String>()
|
|
|
|
|
for (i in 0 until allowed.length()) {
|
|
|
|
|
val v = allowed.optString(i, "")
|
|
|
|
|
if (v.isNotEmpty()) allowedValues.add(v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var matched = false
|
|
|
|
|
if (tags != null) {
|
|
|
|
|
for (i in 0 until tags.length()) {
|
|
|
|
|
val tag = tags.optJSONArray(i) ?: continue
|
|
|
|
|
if (tag.optString(0, "") == tagName && allowedValues.contains(tag.optString(1, ""))) {
|
|
|
|
|
matched = true; break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!matched) return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Crypto helpers ----
|
|
|
|
|
|
|
|
|
|
private fun computeEventId(event: JSONObject): String {
|
|
|
|
|
return try {
|
|
|
|
|
val serialized = JSONArray()
|
|
|
|
|
serialized.put(0)
|
|
|
|
|
serialized.put(event.optString("pubkey", ""))
|
|
|
|
|
serialized.put(event.optLong("created_at", 0))
|
|
|
|
|
serialized.put(event.optInt("kind", 0))
|
|
|
|
|
serialized.put(event.optJSONArray("tags") ?: JSONArray())
|
|
|
|
|
serialized.put(event.optString("content", ""))
|
|
|
|
|
// JSONObject escapes forward slashes (/ -> \/), but NIP-01 event ID hashing
|
|
|
|
|
// requires unescaped slashes. Replace them before hashing.
|
|
|
|
|
val serializedStr = serialized.toString().replace("\\/", "/")
|
|
|
|
|
bytesToHex(sha256(serializedStr.toByteArray(StandardCharsets.UTF_8)))
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun deriveXOnlyPubkey(secretHex: String): String {
|
|
|
|
|
val secret = hexToBytes(secretHex)
|
|
|
|
|
if (secret.size != 32 || !SECP.secKeyVerify(secret)) return ""
|
|
|
|
|
val pubkey65 = try { SECP.pubkeyCreate(secret) } catch (_: Exception) { return "" }
|
|
|
|
|
if (pubkey65.size != 65) return ""
|
|
|
|
|
return bytesToHex(Arrays.copyOfRange(pubkey65, 1, 33))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun schnorrSign(secretHex: String, messageHex: String): String {
|
|
|
|
|
val sk = hexToBytes(secretHex)
|
|
|
|
|
val msg = hexToBytes(messageHex)
|
|
|
|
|
if (sk.size != 32 || msg.size != 32 || !SECP.secKeyVerify(sk)) return ""
|
|
|
|
|
val aux = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
|
|
|
|
val sig = try { SECP.signSchnorr(msg, sk, aux) } catch (_: Exception) { return "" }
|
|
|
|
|
if (sig.size != 64) return ""
|
|
|
|
|
return bytesToHex(sig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun sha256(input: ByteArray): ByteArray =
|
|
|
|
|
try { MessageDigest.getInstance("SHA-256").digest(input) } catch (_: Exception) { ByteArray(32) }
|
|
|
|
|
|
|
|
|
|
private fun hexToBytes(hex: String?): ByteArray {
|
|
|
|
|
var s = hex?.trim()?.lowercase() ?: ""
|
|
|
|
|
if (s.startsWith("0x")) s = s.substring(2)
|
|
|
|
|
if (s.length % 2 == 1) s = "0$s"
|
|
|
|
|
val bytes = ByteArray(s.length / 2)
|
|
|
|
|
var i = 0
|
|
|
|
|
while (i < s.length) {
|
|
|
|
|
val hi = Character.digit(s[i], 16)
|
|
|
|
|
val lo = Character.digit(s[i + 1], 16)
|
|
|
|
|
if (hi < 0 || lo < 0) return ByteArray(0)
|
|
|
|
|
bytes[i / 2] = ((hi shl 4) + lo).toByte()
|
|
|
|
|
i += 2
|
|
|
|
|
}
|
|
|
|
|
return bytes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun bytesToHex(bytes: ByteArray): String {
|
|
|
|
|
val hex = "0123456789abcdef".toCharArray()
|
|
|
|
|
val chars = CharArray(bytes.size * 2)
|
|
|
|
|
for (i in bytes.indices) {
|
|
|
|
|
val v = bytes[i].toInt() and 0xFF
|
|
|
|
|
chars[i * 2] = hex[v ushr 4]
|
|
|
|
|
chars[i * 2 + 1] = hex[v and 0x0F]
|
|
|
|
|
}
|
|
|
|
|
return String(chars)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- NIP-44 encryption (v2: ECDH + HKDF + ChaCha20 + HMAC-SHA256) ----
|
|
|
|
|
|
|
|
|
|
private fun nip44ConversationKey(clientSecret: String, theirPubkey: String): ByteArray {
|
|
|
|
|
val sk = hexToBytes(clientSecret)
|
|
|
|
|
val pk = hexToBytes("02$theirPubkey")
|
|
|
|
|
if (sk.size != 32 || pk.size != 33) return ByteArray(0)
|
|
|
|
|
val shared = try { SECP.pubKeyTweakMul(pk, sk) } catch (_: Exception) { return ByteArray(0) }
|
|
|
|
|
if (shared.size != 65) return ByteArray(0)
|
|
|
|
|
val sharedX = Arrays.copyOfRange(shared, 1, 33)
|
|
|
|
|
return hkdfExtract(sharedX, "nip44-v2".toByteArray(StandardCharsets.UTF_8))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun hkdfExtract(ikm: ByteArray, salt: ByteArray): ByteArray {
|
|
|
|
|
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
|
|
|
|
mac.init(javax.crypto.spec.SecretKeySpec(salt, "HmacSHA256"))
|
|
|
|
|
return mac.doFinal(ikm)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
|
|
|
|
|
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
|
|
|
|
val result = ByteArray(length)
|
|
|
|
|
var prev = ByteArray(0)
|
|
|
|
|
var offset = 0
|
|
|
|
|
var counter = 1
|
|
|
|
|
while (offset < length) {
|
|
|
|
|
mac.init(javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"))
|
|
|
|
|
mac.update(prev)
|
|
|
|
|
mac.update(info)
|
|
|
|
|
mac.update(counter.toByte())
|
|
|
|
|
prev = mac.doFinal()
|
|
|
|
|
val toCopy = minOf(prev.size, length - offset)
|
|
|
|
|
System.arraycopy(prev, 0, result, offset, toCopy)
|
|
|
|
|
offset += toCopy
|
|
|
|
|
counter++
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun hmacSha256(key: ByteArray, vararg parts: ByteArray): ByteArray {
|
|
|
|
|
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
|
|
|
|
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
|
|
|
|
|
for (part in parts) mac.update(part)
|
|
|
|
|
return mac.doFinal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ChaCha20 block function per RFC 8439
|
|
|
|
|
private fun chacha20Block(key: ByteArray, counter: Int, nonce: ByteArray): ByteArray {
|
|
|
|
|
fun Int.rotl(n: Int) = (this shl n) or (this ushr (32 - n))
|
|
|
|
|
val state = IntArray(16)
|
|
|
|
|
state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
|
|
|
|
|
for (i in 0..7) state[4 + i] = (key[i*4].toInt() and 0xFF) or
|
|
|
|
|
((key[i*4+1].toInt() and 0xFF) shl 8) or
|
|
|
|
|
((key[i*4+2].toInt() and 0xFF) shl 16) or
|
|
|
|
|
((key[i*4+3].toInt() and 0xFF) shl 24)
|
|
|
|
|
state[12] = counter
|
|
|
|
|
for (i in 0..2) state[13 + i] = (nonce[i*4].toInt() and 0xFF) or
|
|
|
|
|
((nonce[i*4+1].toInt() and 0xFF) shl 8) or
|
|
|
|
|
((nonce[i*4+2].toInt() and 0xFF) shl 16) or
|
|
|
|
|
((nonce[i*4+3].toInt() and 0xFF) shl 24)
|
|
|
|
|
val working = state.copyOf()
|
|
|
|
|
repeat(10) {
|
|
|
|
|
fun quarterRound(a: Int, b: Int, c: Int, d: Int) {
|
|
|
|
|
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(16)
|
|
|
|
|
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(12)
|
|
|
|
|
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(8)
|
|
|
|
|
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(7)
|
|
|
|
|
}
|
|
|
|
|
quarterRound(0,4,8,12); quarterRound(1,5,9,13); quarterRound(2,6,10,14); quarterRound(3,7,11,15)
|
|
|
|
|
quarterRound(0,5,10,15); quarterRound(1,6,11,12); quarterRound(2,7,8,13); quarterRound(3,4,9,14)
|
|
|
|
|
}
|
|
|
|
|
val out = ByteArray(64)
|
|
|
|
|
for (i in 0..15) {
|
|
|
|
|
val v = working[i] + state[i]
|
|
|
|
|
out[i*4] = v.toByte()
|
|
|
|
|
out[i*4+1] = (v ushr 8).toByte()
|
|
|
|
|
out[i*4+2] = (v ushr 16).toByte()
|
|
|
|
|
out[i*4+3] = (v ushr 24).toByte()
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun chacha20(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
|
|
|
|
|
val out = ByteArray(data.size)
|
|
|
|
|
var counter = 0
|
|
|
|
|
var offset = 0
|
|
|
|
|
while (offset < data.size) {
|
|
|
|
|
val block = chacha20Block(key, counter, nonce)
|
|
|
|
|
val len = minOf(64, data.size - offset)
|
|
|
|
|
for (i in 0 until len) out[offset + i] = (data[offset + i].toInt() xor block[i].toInt()).toByte()
|
|
|
|
|
offset += len
|
|
|
|
|
counter++
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun nip44CalcPaddedLen(len: Int): Int {
|
|
|
|
|
if (len <= 32) return 32
|
|
|
|
|
val nextPower = 1 shl (Math.floor(Math.log((len - 1).toDouble()) / Math.log(2.0)).toInt() + 1)
|
|
|
|
|
val chunk = if (nextPower <= 256) 32 else nextPower / 8
|
|
|
|
|
return chunk * ((len - 1) / chunk + 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun nip44Pad(plaintext: String): ByteArray {
|
|
|
|
|
val unpadded = plaintext.toByteArray(StandardCharsets.UTF_8)
|
|
|
|
|
val len = unpadded.size
|
|
|
|
|
val padded = ByteArray(2 + nip44CalcPaddedLen(len))
|
|
|
|
|
padded[0] = (len ushr 8).toByte()
|
|
|
|
|
padded[1] = len.toByte()
|
|
|
|
|
System.arraycopy(unpadded, 0, padded, 2, len)
|
|
|
|
|
return padded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun nip44Unpad(padded: ByteArray): String {
|
|
|
|
|
val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF)
|
|
|
|
|
if (len == 0 || len > padded.size - 2) return ""
|
|
|
|
|
return String(padded, 2, len, StandardCharsets.UTF_8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun encryptNip44(plaintext: String, conversationKey: ByteArray): String {
|
|
|
|
|
return try {
|
|
|
|
|
val nonce = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
|
|
|
|
val keys = hkdfExpand(conversationKey, nonce, 76)
|
|
|
|
|
val chachaKey = keys.sliceArray(0 until 32)
|
|
|
|
|
val chachaNonce = keys.sliceArray(32 until 44)
|
|
|
|
|
val hmacKey = keys.sliceArray(44 until 76)
|
|
|
|
|
val padded = nip44Pad(plaintext)
|
|
|
|
|
val ciphertext = chacha20(chachaKey, chachaNonce, padded)
|
|
|
|
|
val mac = hmacSha256(hmacKey, nonce, ciphertext)
|
|
|
|
|
val payload = ByteArray(1 + 32 + ciphertext.size + 32)
|
|
|
|
|
payload[0] = 2
|
|
|
|
|
System.arraycopy(nonce, 0, payload, 1, 32)
|
|
|
|
|
System.arraycopy(ciphertext, 0, payload, 33, ciphertext.size)
|
|
|
|
|
System.arraycopy(mac, 0, payload, 33 + ciphertext.size, 32)
|
|
|
|
|
Base64.encodeToString(payload, Base64.NO_WRAP)
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun decryptNip44(payload: String, conversationKey: ByteArray): String {
|
|
|
|
|
return try {
|
|
|
|
|
if (payload.isEmpty() || payload[0] == '#') return ""
|
|
|
|
|
val data = Base64.decode(payload, Base64.NO_WRAP)
|
|
|
|
|
if (data.size < 99 || data[0] != 2.toByte()) return ""
|
|
|
|
|
val nonce = data.sliceArray(1 until 33)
|
|
|
|
|
val ciphertext = data.sliceArray(33 until data.size - 32)
|
|
|
|
|
val mac = data.sliceArray(data.size - 32 until data.size)
|
|
|
|
|
val keys = hkdfExpand(conversationKey, nonce, 76)
|
|
|
|
|
val chachaKey = keys.sliceArray(0 until 32)
|
|
|
|
|
val chachaNonce = keys.sliceArray(32 until 44)
|
|
|
|
|
val hmacKey = keys.sliceArray(44 until 76)
|
|
|
|
|
val expectedMac = hmacSha256(hmacKey, nonce, ciphertext)
|
|
|
|
|
if (!expectedMac.contentEquals(mac)) return ""
|
|
|
|
|
val padded = chacha20(chachaKey, chachaNonce, ciphertext)
|
|
|
|
|
nip44Unpad(padded)
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Signing ----
|
|
|
|
|
|
|
|
|
|
private fun signWithNip01Secret(secretHex: String, eventJson: String, expectedPubkey: String): String {
|
|
|
|
|
return try {
|
|
|
|
|
val secret = hexToBytes(secretHex)
|
|
|
|
|
if (secret.size != 32) return ""
|
|
|
|
|
|
|
|
|
|
val event = JSONObject(eventJson)
|
|
|
|
|
var pubkey = event.optString("pubkey", expectedPubkey)
|
|
|
|
|
if (pubkey.isEmpty()) pubkey = deriveXOnlyPubkey(secretHex)
|
|
|
|
|
if (pubkey.isEmpty()) return ""
|
|
|
|
|
|
|
|
|
|
event.put("pubkey", pubkey)
|
|
|
|
|
val id = computeEventId(event)
|
|
|
|
|
if (id.isEmpty()) return ""
|
|
|
|
|
|
|
|
|
|
val sig = schnorrSign(secretHex, id)
|
|
|
|
|
if (sig.isEmpty()) return ""
|
|
|
|
|
|
|
|
|
|
event.put("id", id)
|
|
|
|
|
event.put("sig", sig)
|
|
|
|
|
event.toString()
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun signWithNip55ContentResolver(packageName: String, eventJson: String, pubkey: String): String {
|
|
|
|
|
val uri = Uri.parse("content://$packageName.SIGN_EVENT")
|
|
|
|
|
var cursor: Cursor? = null
|
|
|
|
|
return try {
|
|
|
|
|
cursor = applicationContext.contentResolver.query(uri, arrayOf(eventJson, "", pubkey), "1", null, null)
|
|
|
|
|
if (cursor == null || !cursor.moveToFirst()) return ""
|
|
|
|
|
val rejIdx = cursor.getColumnIndex("rejected")
|
|
|
|
|
if (rejIdx >= 0) {
|
|
|
|
|
val v = cursor.getString(rejIdx)
|
|
|
|
|
if (v == "1" || v.equals("true", ignoreCase = true)) return REJECTED
|
|
|
|
|
}
|
|
|
|
|
val eventIdx = cursor.getColumnIndex("event")
|
|
|
|
|
if (eventIdx >= 0) cursor.getString(eventIdx) ?: "" else ""
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
""
|
|
|
|
|
} finally {
|
|
|
|
|
cursor?.close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Data types ----
|
|
|
|
|
|
|
|
|
|
private data class SessionInfo(
|
|
|
|
|
val method: String,
|
|
|
|
|
val pubkey: String,
|
|
|
|
|
val session: JSONObject,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private data class Subscription(
|
|
|
|
|
val relay: String,
|
|
|
|
|
val key: String,
|
|
|
|
|
val filters: JSONArray,
|
|
|
|
|
val ignore: JSONArray?,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private class RelayResult {
|
|
|
|
|
val events = mutableListOf<JSONObject>()
|
|
|
|
|
var lastCursor = 0L
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Relay listener ----
|
|
|
|
|
|
|
|
|
|
private inner class RelayListener(
|
|
|
|
|
private val sub: Subscription,
|
|
|
|
|
private val since: Long,
|
|
|
|
|
private val sessionInfo: SessionInfo,
|
|
|
|
|
private val result: RelayResult,
|
|
|
|
|
private val latch: CountDownLatch,
|
|
|
|
|
private val pool: SocketPool,
|
|
|
|
|
) : WebSocketListener() {
|
|
|
|
|
private val subId = UUID.randomUUID().toString().replace("-", "")
|
|
|
|
|
private var done = false
|
|
|
|
|
private var authed = false
|
|
|
|
|
private var authEventId = ""
|
|
|
|
|
private var nip46InFlight = false
|
|
|
|
|
private var pendingDone = false
|
|
|
|
|
|
|
|
|
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
|
|
|
sendReq(webSocket)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun sendReq(webSocket: WebSocket) {
|
|
|
|
|
val req = JSONArray()
|
|
|
|
|
req.put("REQ")
|
|
|
|
|
req.put(subId)
|
|
|
|
|
|
|
|
|
|
for (i in 0 until sub.filters.length()) {
|
|
|
|
|
val filter = sub.filters.optJSONObject(i) ?: continue
|
|
|
|
|
val shaped = JSONObject(filter.toString())
|
|
|
|
|
if (since > 0) shaped.put("since", since + 1)
|
|
|
|
|
shaped.put("limit", 1)
|
|
|
|
|
req.put(shaped)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.length() <= 2) { finish(); return }
|
|
|
|
|
|
|
|
|
|
send(webSocket, req.toString())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
|
|
|
try {
|
|
|
|
|
val message = JSONArray(text)
|
|
|
|
|
Log.d(TAG, "Received message from ${sub.relay}: $text")
|
|
|
|
|
when (message.optString(0, "")) {
|
|
|
|
|
"EVENT" -> {
|
|
|
|
|
val event = message.optJSONObject(2) ?: return
|
|
|
|
|
if (!matchesAnyFilter(sub.filters, event)) return
|
|
|
|
|
if (isIgnored(event)) return
|
|
|
|
|
result.events.add(event)
|
|
|
|
|
val createdAt = event.optLong("created_at", 0L)
|
|
|
|
|
if (createdAt > result.lastCursor) result.lastCursor = createdAt
|
|
|
|
|
}
|
|
|
|
|
"AUTH" -> {
|
|
|
|
|
// Only auth once per connection
|
|
|
|
|
if (!authed) {
|
|
|
|
|
authed = true
|
|
|
|
|
tryAuth(webSocket, message.optString(1, ""))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"OK" -> {
|
|
|
|
|
val okId = message.optString(1, "")
|
|
|
|
|
val accepted = message.optBoolean(2, false)
|
|
|
|
|
if (accepted && okId == authEventId) sendReq(webSocket)
|
|
|
|
|
}
|
|
|
|
|
"EOSE" -> {
|
|
|
|
|
send(webSocket, JSONArray().apply { put("CLOSE"); put(subId) }.toString())
|
|
|
|
|
finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
|
|
|
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
|
|
|
|
|
|
|
|
|
private fun finish() {
|
|
|
|
|
if (done) return
|
|
|
|
|
if (nip46InFlight) { pendingDone = true; return }
|
|
|
|
|
done = true
|
|
|
|
|
latch.countDown()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun isIgnored(event: JSONObject): Boolean {
|
|
|
|
|
val ignore = sub.ignore ?: return false
|
|
|
|
|
for (i in 0 until ignore.length()) {
|
|
|
|
|
val filter = ignore.optJSONObject(i) ?: continue
|
|
|
|
|
if (matchesFilter(filter, event)) return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun matchesAnyFilter(filters: JSONArray, event: JSONObject): Boolean {
|
|
|
|
|
for (i in 0 until filters.length()) {
|
|
|
|
|
val filter = filters.optJSONObject(i) ?: continue
|
|
|
|
|
if (matchesFilter(filter, event)) return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- NIP-42 auth ----
|
|
|
|
|
|
|
|
|
|
private fun tryAuth(webSocket: WebSocket, challenge: String) {
|
|
|
|
|
if (challenge.isEmpty()) return
|
|
|
|
|
when (sessionInfo.method) {
|
|
|
|
|
"nip01" -> tryNip01Auth(webSocket, challenge)
|
|
|
|
|
"nip55" -> tryNip55Auth(webSocket, challenge)
|
|
|
|
|
"nip46" -> tryNip46Auth(webSocket, challenge)
|
|
|
|
|
// Pomade background auth is not supported: properly delegating to the Pomade signer
|
|
|
|
|
// from a background worker is complex, usage is rare, and relays that require auth
|
|
|
|
|
// may still be readable without it.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun buildAuthEvent(challenge: String): JSONObject {
|
|
|
|
|
return JSONObject().apply {
|
|
|
|
|
put("kind", KIND_RELAY_AUTH)
|
|
|
|
|
put("pubkey", sessionInfo.pubkey)
|
|
|
|
|
put("created_at", System.currentTimeMillis() / 1000L)
|
|
|
|
|
put("content", "")
|
|
|
|
|
put("id", "")
|
|
|
|
|
put("sig", "")
|
|
|
|
|
put("tags", JSONArray().apply {
|
|
|
|
|
put(JSONArray().apply { put("relay"); put(sub.relay) })
|
|
|
|
|
put(JSONArray().apply { put("challenge"); put(challenge) })
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun sendAuthMessage(webSocket: WebSocket, signedEventJson: String): Boolean {
|
|
|
|
|
if (signedEventJson.isEmpty() || signedEventJson == REJECTED) return false
|
|
|
|
|
return try {
|
|
|
|
|
val event = JSONObject(signedEventJson)
|
|
|
|
|
authEventId = event.optString("id", "")
|
|
|
|
|
send(webSocket, JSONArray().apply { put("AUTH"); put(event) }.toString())
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun send(webSocket: WebSocket, message: String): Boolean {
|
|
|
|
|
Log.d(TAG, "Sending message to ${webSocket.request().url}: $message")
|
|
|
|
|
return webSocket.send(message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
|
|
|
|
val secret = sessionInfo.session.optString("secret", "")
|
|
|
|
|
if (secret.isEmpty()) return false
|
|
|
|
|
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
|
|
|
|
return sendAuthMessage(webSocket, signed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
|
|
|
|
|
val signerPackage = sessionInfo.session.optString("signer", "")
|
|
|
|
|
if (signerPackage.isEmpty()) return false
|
|
|
|
|
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
|
|
|
|
return sendAuthMessage(webSocket, signed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
|
|
|
|
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
|
|
|
|
|
val clientSecret = sessionInfo.session.optString("secret", "")
|
|
|
|
|
val signerPubkey = handler.optString("pubkey", "")
|
|
|
|
|
val relays = handler.optJSONArray("relays")
|
|
|
|
|
|
|
|
|
|
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
|
|
|
|
|
|
|
|
|
|
val clientPubkey = deriveXOnlyPubkey(clientSecret)
|
|
|
|
|
if (clientPubkey.isEmpty()) return false
|
|
|
|
|
|
|
|
|
|
val authEventJson = buildAuthEvent(challenge).toString()
|
|
|
|
|
|
|
|
|
|
nip46InFlight = true
|
|
|
|
|
var success = false
|
|
|
|
|
try {
|
|
|
|
|
for (i in 0 until relays.length()) {
|
|
|
|
|
val signerRelay = relays.optString(i, "").trim()
|
|
|
|
|
if (!signerRelay.startsWith("wss://") && !signerRelay.startsWith("ws://")) continue
|
|
|
|
|
if (tryNip46ViaRelay(webSocket, signerRelay, clientSecret, clientPubkey, signerPubkey, authEventJson)) { success = true; break }
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
nip46InFlight = false
|
|
|
|
|
if (pendingDone) finish()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return success
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun tryNip46ViaRelay(
|
|
|
|
|
relaySocket: WebSocket,
|
|
|
|
|
signerRelay: String,
|
|
|
|
|
clientSecret: String,
|
|
|
|
|
clientPubkey: String,
|
|
|
|
|
signerPubkey: String,
|
|
|
|
|
authEventJson: String,
|
|
|
|
|
): Boolean {
|
|
|
|
|
val localLatch = CountDownLatch(1)
|
|
|
|
|
val signedEvent = StringBuilder()
|
|
|
|
|
val requestId = UUID.randomUUID().toString().replace("-", "")
|
|
|
|
|
|
|
|
|
|
val signerSocket = pool.open(signerRelay, object : WebSocketListener() {
|
|
|
|
|
private var done = false
|
|
|
|
|
|
|
|
|
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
|
|
|
try {
|
|
|
|
|
val rpcEnvelope = JSONObject().apply {
|
|
|
|
|
put("kind", KIND_NIP46_RPC)
|
|
|
|
|
put("pubkey", clientPubkey)
|
|
|
|
|
put("created_at", System.currentTimeMillis() / 1000L)
|
|
|
|
|
put("content", encryptNip44(
|
|
|
|
|
JSONObject().apply {
|
|
|
|
|
put("id", requestId)
|
|
|
|
|
put("method", "sign_event")
|
|
|
|
|
put("params", JSONArray().apply { put(authEventJson) })
|
|
|
|
|
}.toString(),
|
|
|
|
|
nip44ConversationKey(clientSecret, signerPubkey),
|
|
|
|
|
))
|
|
|
|
|
put("id", "")
|
|
|
|
|
put("sig", "")
|
|
|
|
|
put("tags", JSONArray().apply { put(JSONArray().apply { put("p"); put(signerPubkey) }) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val signedEnvelope = signWithNip01Secret(clientSecret, rpcEnvelope.toString(), clientPubkey)
|
|
|
|
|
if (signedEnvelope.isEmpty()) { finish(); return }
|
|
|
|
|
|
|
|
|
|
val sentAt = System.currentTimeMillis() / 1000L
|
|
|
|
|
send(webSocket, JSONArray().apply { put("EVENT"); put(JSONObject(signedEnvelope)) }.toString())
|
|
|
|
|
send(webSocket, JSONArray().apply {
|
|
|
|
|
put("REQ")
|
|
|
|
|
put(requestId)
|
|
|
|
|
put(JSONObject().apply {
|
|
|
|
|
put("#p", JSONArray().apply { put(clientPubkey) })
|
|
|
|
|
put("kinds", JSONArray().apply { put(KIND_NIP46_RPC) })
|
|
|
|
|
put("since", sentAt)
|
|
|
|
|
put("limit", 10)
|
|
|
|
|
})
|
|
|
|
|
}.toString())
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
|
|
|
try {
|
|
|
|
|
val message = JSONArray(text)
|
|
|
|
|
val msgType = message.optString(0, "")
|
|
|
|
|
Log.d(TAG, "NIP-46 signer message: $msgType from ${webSocket.request().url}")
|
|
|
|
|
if (msgType != "EVENT") return
|
|
|
|
|
val event = message.optJSONObject(2) ?: return
|
|
|
|
|
|
|
|
|
|
val tags = event.optJSONArray("tags")
|
|
|
|
|
var hasP = false
|
|
|
|
|
if (tags != null) {
|
|
|
|
|
for (i in 0 until tags.length()) {
|
|
|
|
|
val tag = tags.optJSONArray(i) ?: continue
|
|
|
|
|
if (tag.optString(0, "") == "p" && tag.optString(1, "") == clientPubkey) { hasP = true; break }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!hasP) { Log.d(TAG, "NIP-46 event missing p tag for client"); return }
|
|
|
|
|
|
|
|
|
|
val decryptedContent = decryptNip44(event.optString("content", ""), nip44ConversationKey(clientSecret, signerPubkey))
|
|
|
|
|
Log.d(TAG, "NIP-46 decrypted response: $decryptedContent")
|
|
|
|
|
if (decryptedContent.isEmpty()) return
|
|
|
|
|
val payload = JSONObject(decryptedContent)
|
|
|
|
|
if (requestId == payload.optString("id", "")) {
|
|
|
|
|
val result = payload.optString("result", "")
|
|
|
|
|
if (result.isNotEmpty()) {
|
|
|
|
|
signedEvent.setLength(0)
|
|
|
|
|
signedEvent.append(result)
|
|
|
|
|
finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
Log.e(TAG, "NIP-46 signer message error", e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
|
|
|
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
|
|
|
|
|
|
|
|
|
private fun finish() {
|
|
|
|
|
if (!done) { done = true; localLatch.countDown() }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
localLatch.await(5, TimeUnit.SECONDS)
|
|
|
|
|
} catch (_: InterruptedException) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (signedEvent.isEmpty()) return false
|
|
|
|
|
|
|
|
|
|
val authEvent = JSONObject(signedEvent.toString())
|
|
|
|
|
authEventId = authEvent.optString("id", "")
|
|
|
|
|
val authMessage = JSONArray().apply { put("AUTH"); put(authEvent) }.toString()
|
|
|
|
|
Log.d(TAG, "NIP-46 sending AUTH to relay ${relaySocket.request().url}: $authMessage")
|
|
|
|
|
return try {
|
|
|
|
|
relaySocket.send(authMessage)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
Log.e(TAG, "NIP-46 failed to send AUTH", e)
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|