diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b45f714..77d1f7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -173,4 +173,6 @@ dependencies { implementation(libs.kmp.tor.runtime.service.ui) implementation(libs.kmp.tor.resource.exec) implementation(libs.negentropy) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) } diff --git a/app/src/main/java/com/greenart7c3/citrine/Citrine.kt b/app/src/main/java/com/greenart7c3/citrine/Citrine.kt index 0c8b6b7..601ae29 100644 --- a/app/src/main/java/com/greenart7c3/citrine/Citrine.kt +++ b/app/src/main/java/com/greenart7c3/citrine/Citrine.kt @@ -7,6 +7,10 @@ import android.app.PendingIntent import android.content.Intent import android.content.IntentFilter import android.os.Build +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory import com.greenart7c3.citrine.database.AppDatabase import com.greenart7c3.citrine.logs.Log import com.greenart7c3.citrine.okhttp.HttpClientManager @@ -31,8 +35,22 @@ import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -class Citrine : Application() { +class Citrine : + Application(), + SingletonImageLoader.Factory { val crashReportCache: CrashReportCache by lazy { CrashReportCache(this.applicationContext) } + + // Coil image loader whose network fetches route through the app's OkHttp client so nsite + // icon downloads honor the relay's Tor/proxy fail-closed policy instead of going direct. + override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = { HttpClientManager.getHttpClient(Settings.proxyAllUrls) }, + ), + ) + } + .build() val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Log.e("AmberCoroutine", "Caught exception: ${throwable.message}", throwable) diff --git a/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt b/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt index 6284af0..4dfc81c 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt @@ -82,6 +82,7 @@ import io.ktor.utils.io.copyTo import io.ktor.websocket.Frame import io.ktor.websocket.WebSocketDeflateExtension import io.ktor.websocket.readText +import java.io.File import java.net.ServerSocket import java.util.concurrent.ConcurrentHashMap import java.util.zip.Deflater @@ -910,6 +911,94 @@ class CustomWebSocketServer( ) } + /** + * Serves a file from an app-internal [rootDir] (used by installed nsites). Mirrors + * [serveIndex] but reads from a plain [File] tree instead of a SAF DocumentFile. Paths + * are canonicalized and rejected if they escape [rootDir] (path-traversal guard). Falls + * back to index.html for extension-less routes and to 404.html (with a 404 status) when + * nothing matches, per NIP-5A. + */ + private suspend fun serveFromDir( + call: ApplicationCall, + rootDir: File, + ) { + val requestedPath = call.request.uri.substringBefore('?').trimStart('/') + val rootCanonical = rootDir.canonicalFile + + fun resolveSafe(path: String): File? { + if (path.isEmpty()) return null + val candidate = File(rootCanonical, path).canonicalFile + return if (candidate.path == rootCanonical.path || candidate.path.startsWith(rootCanonical.path + File.separator)) { + candidate + } else { + null + } + } + + var target = resolveSafe(requestedPath)?.takeIf { it.isFile } + // SPA/index fallback for the root or extension-less routes. + if (target == null && (requestedPath.isEmpty() || !requestedPath.substringAfterLast('/').contains('.'))) { + target = resolveSafe("index.html")?.takeIf { it.isFile } + } + // 404 fallback. + if (target == null) { + target = resolveSafe("404.html")?.takeIf { it.isFile } + } + + if (target == null) { + return call.respond(HttpStatusCode.NotFound, null) + } + + val status = if (target.name == "404.html") HttpStatusCode.NotFound else HttpStatusCode.OK + call.respondOutputStream( + contentType = ContentType.defaultForFilePath(target.name), + status = status, + ) { + target.inputStream().use { it.copyTo(this) } + } + } + + fun startNsiteServerFor( + clientName: String, + rootDir: File, + ): WebClientServer { + val port = randomFreePort() + + val server = embeddedServer( + CIO, + host = "127.0.0.1", + port = port, + ) { + routing { + get("{...}") { + serveFromDir(call, rootDir) + } + } + }.start(wait = false) + + return WebClientServer( + name = clientName, + rootUri = rootDir.toURI().toString().toUri(), + port = port, + server = server, + ) + } + + /** Hot-adds (or replaces) a running server for an installed nsite without restarting the relay. */ + fun startNsiteServer(nsite: NsiteInfo) { + val dir = File(Citrine.instance.filesDir, "nsites/${nsite.folderName}") + if (!dir.isDirectory) return + val key = "/${nsite.folderName}" + webClientServers.remove(key)?.server?.stop() + webClientServers[key] = startNsiteServerFor(nsite.folderName, dir) + Log.d(Citrine.TAG, "Started nsite '${nsite.folderName}'") + } + + /** Stops and removes a running nsite server (used on delete). */ + fun stopNsiteServer(folderName: String) { + webClientServers.remove("/$folderName")?.server?.stop() + } + val proxyClient = HttpClient(io.ktor.client.engine.cio.CIO) { expectSuccess = false } @@ -992,6 +1081,20 @@ class CustomWebSocketServer( "Started web client '$name' on port ${clientServer.port}", ) } + + Settings.nsites.forEach { nsite -> + val dir = File(Citrine.instance.filesDir, "nsites/${nsite.folderName}") + if (dir.isDirectory) { + val key = "/${nsite.folderName}" + val clientServer = startNsiteServerFor(nsite.folderName, dir) + webClientServers[key] = clientServer + + Log.d( + Citrine.TAG, + "Started nsite '${nsite.folderName}' on port ${clientServer.port}", + ) + } + } } @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) diff --git a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt index 1ebc980..1770670 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt @@ -16,6 +16,14 @@ object Settings { "wss://directory.yabu.me/", ) + // Suggested relays for discovering nsites (NIP-5A). Used by the "reset to default" action + // in the nsite relay editor; an empty user list falls back to the aggregator relays. + val DEFAULT_NSITE_RELAYS = setOf( + "wss://nsite.run/", + "wss://nos.lol/", + "wss://nostr.land/", + ) + var allowedKinds: Set = emptySet() var allowedPubKeys: Set = emptySet() var allowedTaggedPubKeys: Set = emptySet() @@ -47,6 +55,18 @@ object Settings { var onionHostname = "" var webClients = mutableMapOf() + // Installed nsites (NIP-5A static websites). Downloaded to filesDir/nsites/ + // and served through the same localhost web-client mechanism as [webClients]. + var nsites: MutableList = mutableListOf() + + // Epoch seconds of the last daily nsite update check. Gated like [lastBackup] so the + // 100s service timer only runs the check roughly once per day. + var lastNsiteCheck: Long = 0L + + // Relays queried to discover nsites. nsites use only this set (never the aggregator + // relays); an empty list falls back to [DEFAULT_NSITE_RELAYS]. + var nsiteRelays: Set = DEFAULT_NSITE_RELAYS + var relayAggregatorEnabled = false var aggregatorPubkey = "" var relayAggregatorKinds: Set = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) @@ -115,6 +135,9 @@ object Settings { useTor = false onionHostname = "" webClients = mutableMapOf() + nsites = mutableListOf() + lastNsiteCheck = 0L + nsiteRelays = DEFAULT_NSITE_RELAYS relayAggregatorEnabled = false aggregatorPubkey = "" relayAggregatorKinds = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) @@ -133,8 +156,34 @@ object Settings { fun webClientFromJson(json: String): MutableMap = JacksonMapper.mapper.readValue>(json) fun webClientsToJson(): String = JacksonMapper.mapper.writeValueAsString(webClients) + + fun nsitesFromJson(json: String): MutableList = JacksonMapper.mapper.readValue>(json) + + fun nsitesToJson(): String = JacksonMapper.mapper.writeValueAsString(nsites) } +/** + * An installed nsite (NIP-5A static website). + * + * [address] is the canonical identifier: "15128:" for a root site or + * "35128::" for a named site. [folderName] is a filesystem-safe slug + * (prefixed with "nsite_") used both as the on-disk directory under filesDir/nsites and + * as the ".localhost" subdomain the relay serves it from. [aggregateHash] is + * the last-applied ["x", , "aggregate"] value, compared against a freshly fetched + * manifest to detect updates. + */ +data class NsiteInfo( + val address: String = "", + val pubkey: String = "", + val kind: Int = 0, + val dTag: String = "", + val displayName: String = "", + val folderName: String = "", + val aggregateHash: String = "", + val autoUpdate: Boolean = false, + val lastChecked: Long = 0L, +) + enum class OlderThan { DAY, WEEK, diff --git a/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt b/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt index 196458a..c31e25d 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt @@ -28,6 +28,12 @@ class ClipboardReceiver : BroadcastReceiver() { Citrine.instance.applicationScope.launch { Citrine.instance.client.disconnect() } + } else if (intent != null && intent.hasExtra("nsite_update")) { + val address = intent.getStringExtra("nsite_update") ?: return + NotificationManagerCompat.from(context).cancel(NSITE_UPDATE_NOTIFICATION_ID) + Citrine.instance.applicationScope.launch { + NsiteManager.applyUpdateByAddress(address) + } } } } diff --git a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt index 1585c1e..2df1304 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt @@ -34,6 +34,9 @@ object PrefKeys { const val ONION_HOSTNAME = "onion_hostname" const val WEB_CLIENTS = "web_clients" + const val NSITES = "nsites" + const val LAST_NSITE_CHECK = "last_nsite_check" + const val NSITE_RELAYS = "nsite_relays" const val RELAY_AGGREGATOR_ENABLED = "relay_aggregator_enabled" const val AGGREGATOR_PUBKEY = "aggregator_pubkey" @@ -97,6 +100,13 @@ object LocalPreferences { } else { remove(PrefKeys.WEB_CLIENTS) } + if (settings.nsites.isNotEmpty()) { + putString(PrefKeys.NSITES, Settings.nsitesToJson()) + } else { + remove(PrefKeys.NSITES) + } + putLong(PrefKeys.LAST_NSITE_CHECK, settings.lastNsiteCheck) + putStringSet(PrefKeys.NSITE_RELAYS, settings.nsiteRelays) putBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, settings.relayAggregatorEnabled) putString(PrefKeys.AGGREGATOR_PUBKEY, settings.aggregatorPubkey) @@ -155,6 +165,11 @@ object LocalPreferences { prefs.getString(PrefKeys.WEB_CLIENTS, null)?.let { Settings.webClients = Settings.webClientFromJson(it) } + prefs.getString(PrefKeys.NSITES, null)?.let { + Settings.nsites = Settings.nsitesFromJson(it) + } + Settings.lastNsiteCheck = prefs.getLong(PrefKeys.LAST_NSITE_CHECK, 0L) + Settings.nsiteRelays = prefs.getStringSet(PrefKeys.NSITE_RELAYS, null) ?: Settings.DEFAULT_NSITE_RELAYS Settings.relayAggregatorEnabled = prefs.getBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, false) Settings.aggregatorPubkey = prefs.getString(PrefKeys.AGGREGATOR_PUBKEY, "") ?: "" diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt new file mode 100644 index 0000000..1546d72 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -0,0 +1,588 @@ +package com.greenart7c3.citrine.service + +import com.greenart7c3.citrine.Citrine +import com.greenart7c3.citrine.logs.Log +import com.greenart7c3.citrine.okhttp.HttpClientManager +import com.greenart7c3.citrine.server.NsiteInfo +import com.greenart7c3.citrine.server.Settings +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Request + +/** + * Discovers, downloads, and updates nsites (NIP-5A static websites). + * + * Manifests are kind 15128 (root site) or 35128 (named site, requires a `d` tag). Each + * manifest has `["path", "/url/path", ""]` tags mapping URLs to blob hashes and an + * `["x", "", "aggregate"]` tag used to detect content changes. Blobs are downloaded + * from Blossom servers (manifest `server` hints, else the author's kind-10063 list) and + * verified against their path hash before being written to filesDir/nsites/. + */ +object NsiteManager { + private const val TAG = "NsiteManager" + + const val KIND_ROOT_SITE = 15128 + const val KIND_NAMED_SITE = 35128 + private const val KIND_SERVER_LIST = 10063 + private val NSITE_KINDS = listOf(KIND_ROOT_SITE, KIND_NAMED_SITE) + + private const val BATCH_FETCH_TIMEOUT_MS = 15_000L + private const val DISCOVERY_TIMEOUT_MS = 20_000L + private const val DISCOVERY_LIMIT = 500 + + private val DEFAULT_BLOSSOM_SERVERS = listOf("https://blossom.primal.net") + + // Common raster icon filenames, in priority order. Coil can't decode .ico/.svg, so those + // are intentionally excluded — a site with only those simply shows the placeholder. + private val ICON_NAMES = listOf( + "favicon.png", + "icon.png", + "apple-touch-icon.png", + "logo.png", + "icon-192.png", + "icon-512.png", + "favicon-32x32.png", + ) + + private val installing = AtomicBoolean(false) + + sealed interface DiscoveryState { + data object Idle : DiscoveryState + data object Loading : DiscoveryState + data class Loaded(val nsites: List) : DiscoveryState + data class Error(val message: String) : DiscoveryState + } + + data class DiscoveredNsite( + val address: String, + val pubkey: String, + val kind: Int, + val dTag: String, + val displayName: String, + val authorName: String, + val aggregateHash: String, + val serverHints: List, + val iconUrl: String?, + val lastUpdated: Long, + val alreadyInstalled: Boolean, + ) + + data class NsiteUpdate( + val nsite: NsiteInfo, + val newManifest: Event, + val newAggregateHash: String, + ) + + private val _discoveryState = MutableStateFlow(DiscoveryState.Idle) + val discoveryState: StateFlow = _discoveryState.asStateFlow() + + // region relays + + private fun normalizeRemote(raw: String): NormalizedRelayUrl? { + val n = RelayUrlNormalizer.normalizeOrNull(raw) ?: return null + if (Citrine.instance.isPrivateIp(n.url)) return null + if (!Settings.useProxy && Citrine.instance.isOnionUrl(n.url)) return null + return NormalizedRelayUrl(url = n.url) + } + + private fun discoveryRelays(): List { + // nsites use their own relay set only — never the aggregator relays. An empty user + // list falls back to the built-in defaults. + val raw = Settings.nsiteRelays.ifEmpty { Settings.DEFAULT_NSITE_RELAYS } + return raw.mapNotNull { normalizeRemote(it) }.ifEmpty { Settings.DEFAULT_NSITE_RELAYS.mapNotNull { normalizeRemote(it) } } + } + + // endregion + + // region manifest parsing + + private fun dTagOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) ?: "" + + private fun addressOf(ev: Event): String = if (ev.kind == KIND_ROOT_SITE) { + "$KIND_ROOT_SITE:${ev.pubKey}" + } else { + "$KIND_NAMED_SITE:${ev.pubKey}:${dTagOf(ev)}" + } + + private fun aggregateHashOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 3 && it[0] == "x" && it[2] == "aggregate" }?.get(1) ?: "" + + /** The nsite's own human-readable name from the manifest `title` tag, if present. */ + private fun titleOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 2 && it[0] == "title" }?.get(1)?.takeIf { it.isNotBlank() } ?: "" + + /** Returns (urlPath, sha256) for every `path` tag. */ + private fun pathTagsOf(ev: Event): List> = ev.tags.filter { it.size >= 3 && it[0] == "path" }.map { it[1] to it[2] } + + private fun serverHintsOf(ev: Event): List = ev.tags.filter { it.size >= 2 && it[0] == "server" }.map { it[1] } + + /** sha256 of the highest-priority decodable icon file mapped by a `path` tag, if any. */ + private fun iconHashOf(ev: Event): String? { + val paths = pathTagsOf(ev) + for (name in ICON_NAMES) { + val match = paths.firstOrNull { (urlPath, _) -> urlPath.substringAfterLast('/').lowercase() == name } + if (match != null) return match.second + } + return null + } + + /** Builds a Blossom URL for an icon, or null when there's no icon hash. */ + private fun iconUrlOf(ev: Event): String? { + val hash = iconHashOf(ev) ?: return null + val server = serverHintsOf(ev).mapNotNull { normalizeServerUrl(it) }.firstOrNull() + ?: DEFAULT_BLOSSOM_SERVERS.first() + return "$server/$hash" + } + + /** The on-disk icon file for an installed nsite, scanning the known icon filenames. */ + fun localIconFile(folderName: String): File? { + val dir = File(Citrine.instance.filesDir, "nsites/$folderName") + for (name in ICON_NAMES) { + val file = File(dir, name) + if (file.isFile) return file + } + return null + } + + private fun displayNameOf(metadata: Event?, fallback: String): String { + if (metadata == null) return fallback + return try { + val node = JacksonMapper.mapper.readTree(metadata.content) + val name = node.get("display_name")?.asText()?.takeIf { it.isNotBlank() } + ?: node.get("name")?.asText()?.takeIf { it.isNotBlank() } + name ?: fallback + } catch (e: Exception) { + fallback + } + } + + /** "nsite" + first 16 hex chars of sha256(address): a deterministic, hostname-safe slug. */ + private fun folderNameFor(address: String): String = "nsite" + sha256Hex(address.toByteArray()).take(16) + + private fun shortPubkey(pubkey: String): String = if (pubkey.length > 12) "${pubkey.take(8)}…${pubkey.takeLast(4)}" else pubkey + + // endregion + + // region discovery + + suspend fun discover(): List { + _discoveryState.value = DiscoveryState.Loading + return try { + val relays = discoveryRelays() + if (relays.isEmpty()) { + _discoveryState.value = DiscoveryState.Loaded(emptyList()) + return emptyList() + } + val manifests = collectManifests(relays) + // Dedupe by address keeping the newest manifest. + val byAddress = HashMap() + for (ev in manifests) { + val addr = addressOf(ev) + val prev = byAddress[addr] + if (prev == null || prev.createdAt < ev.createdAt) byAddress[addr] = ev + } + + val names = fetchDisplayNames(byAddress.values.map { it.pubKey }.toSet(), relays) + val installedAddresses = Settings.nsites.map { it.address }.toSet() + + val result = byAddress.values.map { ev -> + val addr = addressOf(ev) + val dTag = if (ev.kind == KIND_NAMED_SITE) dTagOf(ev) else "" + val authorName = displayNameOf(names[ev.pubKey], "") + // Prefer the nsite's own title; fall back to the d tag, then the author's + // profile name, then a short pubkey. + val name = titleOf(ev).ifBlank { dTag.ifBlank { authorName.ifBlank { shortPubkey(ev.pubKey) } } } + DiscoveredNsite( + address = addr, + pubkey = ev.pubKey, + kind = ev.kind, + dTag = dTag, + displayName = name, + authorName = authorName, + aggregateHash = aggregateHashOf(ev), + serverHints = serverHintsOf(ev), + iconUrl = iconUrlOf(ev), + lastUpdated = ev.createdAt, + alreadyInstalled = installedAddresses.contains(addr), + ) + }.sortedByDescending { it.lastUpdated } + + _discoveryState.value = DiscoveryState.Loaded(result) + result + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "nsite discovery failed", e) + _discoveryState.value = DiscoveryState.Error(e.message ?: "Discovery failed") + emptyList() + } + } + + private suspend fun collectManifests(relays: List): List { + val client = Citrine.instance.client + if (!client.isActive()) client.connect() + + val results = ConcurrentHashMap() + val subId = newSubId() + val eoseSeen: MutableSet = ConcurrentHashMap.newKeySet() + val expected = relays.toSet() + val done = CompletableDeferred() + + val collector = object : RelayConnectionListener { + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + when (msg) { + is EventMessage -> { + if (msg.subId != subId) return + val ev = msg.event + if (ev.kind == KIND_ROOT_SITE || ev.kind == KIND_NAMED_SITE) { + results[ev.id] = ev + } + } + is EoseMessage -> { + if (msg.subId != subId) return + markDone(relay) + } + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) = markDone(relay) + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) = markDone(relay) + + private fun markDone(relay: IRelayClient) { + if (expected.contains(relay.url)) { + eoseSeen.add(relay.url) + if (eoseSeen.size >= expected.size) done.complete(Unit) + } + } + } + + client.addConnectionListener(collector) + try { + val filters = listOf(Filter(kinds = NSITE_KINDS, limit = DISCOVERY_LIMIT)) + runCatching { client.subscribe(subId, relays.associateWith { filters }) } + .onFailure { Log.e(TAG, "nsite discovery subscribe failed", it) } + withTimeoutOrNull(DISCOVERY_TIMEOUT_MS) { done.await() } + } finally { + runCatching { client.unsubscribe(subId) } + client.removeConnectionListener(collector) + } + return results.values.toList() + } + + private suspend fun fetchDisplayNames( + pubkeys: Set, + relays: List, + ): Map { + if (pubkeys.isEmpty()) return emptyMap() + val client = Citrine.instance.client + if (!client.isActive()) client.connect() + + val results = ConcurrentHashMap() + val subId = newSubId() + val eoseSeen: MutableSet = ConcurrentHashMap.newKeySet() + val expected = relays.toSet() + val done = CompletableDeferred() + + val collector = object : RelayConnectionListener { + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + when (msg) { + is EventMessage -> { + if (msg.subId != subId) return + val ev = msg.event + if (ev.kind != 0) return + val prev = results[ev.pubKey] + if (prev == null || prev.createdAt < ev.createdAt) results[ev.pubKey] = ev + } + is EoseMessage -> { + if (msg.subId != subId) return + markDone(relay) + } + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) = markDone(relay) + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) = markDone(relay) + + private fun markDone(relay: IRelayClient) { + if (expected.contains(relay.url)) { + eoseSeen.add(relay.url) + if (eoseSeen.size >= expected.size) done.complete(Unit) + } + } + } + + client.addConnectionListener(collector) + try { + val filters = listOf(Filter(kinds = listOf(0), authors = pubkeys.toList())) + runCatching { client.subscribe(subId, relays.associateWith { filters }) } + withTimeoutOrNull(BATCH_FETCH_TIMEOUT_MS) { done.await() } + } finally { + runCatching { client.unsubscribe(subId) } + client.removeConnectionListener(collector) + } + return results + } + + // endregion + + // region single fetches + + private suspend fun fetchManifest( + pubkey: String, + kind: Int, + dTag: String, + relays: List, + ): Event? { + if (relays.isEmpty()) return null + val client = Citrine.instance.client + val filters = listOf( + Filter( + kinds = listOf(kind), + authors = listOf(pubkey), + tags = if (dTag.isNotEmpty()) mapOf("d" to listOf(dTag)) else null, + limit = 1, + ), + ) + val subId = newSubId() + if (!client.isActive()) client.connect() + return try { + withTimeoutOrNull(BATCH_FETCH_TIMEOUT_MS) { + client.fetchFirst(subId, relays.associateWith { filters }) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "fetch manifest $kind:$pubkey failed", e) + null + } finally { + runCatching { client.unsubscribe(subId) } + } + } + + private suspend fun resolveServers(pubkey: String, manifest: Event, relays: List): List { + val fromManifest = serverHintsOf(manifest).mapNotNull { normalizeServerUrl(it) } + if (fromManifest.isNotEmpty()) return fromManifest.distinct() + + val serverList = fetchManifest(pubkey, KIND_SERVER_LIST, "", relays) + val fromList = serverList?.let { serverHintsOf(it).mapNotNull { url -> normalizeServerUrl(url) } } ?: emptyList() + return fromList.distinct().ifEmpty { DEFAULT_BLOSSOM_SERVERS } + } + + private fun normalizeServerUrl(raw: String): String? { + val trimmed = raw.trim().trimEnd('/') + if (!trimmed.startsWith("https://")) return null + return trimmed + } + + // endregion + + // region install / update + + /** Installs an nsite discovered via [discover]. Returns the stored [NsiteInfo] or a failure. */ + suspend fun install( + discovered: DiscoveredNsite, + onProgress: ((downloaded: Int, total: Int) -> Unit)? = null, + ): Result = withContext(Dispatchers.IO) { + if (Citrine.isImportingEvents) return@withContext Result.failure(IllegalStateException("Busy importing events")) + if (!installing.compareAndSet(false, true)) return@withContext Result.failure(IllegalStateException("Another nsite operation is running")) + try { + val relays = discoveryRelays() + val manifest = fetchManifest(discovered.pubkey, discovered.kind, discovered.dTag, relays) + ?: return@withContext Result.failure(IllegalStateException("Could not fetch manifest")) + downloadAndStore(discovered.address, discovered.pubkey, discovered.kind, discovered.dTag, discovered.displayName, manifest, relays, onProgress = onProgress) + } finally { + installing.set(false) + } + } + + /** Re-downloads an nsite whose manifest changed. */ + suspend fun applyUpdate(update: NsiteUpdate): Result = withContext(Dispatchers.IO) { + if (Citrine.isImportingEvents) return@withContext Result.failure(IllegalStateException("Busy importing events")) + if (!installing.compareAndSet(false, true)) return@withContext Result.failure(IllegalStateException("Another nsite operation is running")) + try { + val nsite = update.nsite + val relays = discoveryRelays() + downloadAndStore(nsite.address, nsite.pubkey, nsite.kind, nsite.dTag, nsite.displayName, update.newManifest, relays, nsite.autoUpdate) + } finally { + installing.set(false) + } + } + + suspend fun applyUpdateByAddress(address: String): Result { + val nsite = Settings.nsites.firstOrNull { it.address == address } + ?: return Result.failure(IllegalStateException("nsite not installed")) + val relays = discoveryRelays() + val manifest = fetchManifest(nsite.pubkey, nsite.kind, nsite.dTag, relays) + ?: return Result.failure(IllegalStateException("Could not fetch manifest")) + return applyUpdate(NsiteUpdate(nsite, manifest, aggregateHashOf(manifest))) + } + + private suspend fun downloadAndStore( + address: String, + pubkey: String, + kind: Int, + dTag: String, + displayName: String, + manifest: Event, + relays: List, + autoUpdate: Boolean = false, + onProgress: ((downloaded: Int, total: Int) -> Unit)? = null, + ): Result { + val paths = pathTagsOf(manifest) + if (paths.isEmpty()) return Result.failure(IllegalStateException("Manifest has no files")) + + val servers = resolveServers(pubkey, manifest, relays) + if (servers.isEmpty()) return Result.failure(IllegalStateException("No blossom servers found")) + + val folderName = folderNameFor(address) + val rootDir = File(Citrine.instance.filesDir, "nsites/$folderName") + val tmpDir = File(Citrine.instance.filesDir, "nsites/$folderName.tmp") + tmpDir.deleteRecursively() + tmpDir.mkdirs() + + try { + val tmpCanonical = tmpDir.canonicalFile + onProgress?.invoke(0, paths.size) + paths.forEachIndexed { index, (urlPath, sha256) -> + val outFile = File(tmpCanonical, urlPath.trimStart('/')).canonicalFile + if (outFile.path != tmpCanonical.path && !outFile.path.startsWith(tmpCanonical.path + File.separator)) { + return Result.failure(IllegalStateException("Illegal path in manifest: $urlPath")) + } + val bytes = downloadBlob(servers, sha256) + ?: return Result.failure(IllegalStateException("Failed to download $urlPath")) + outFile.parentFile?.mkdirs() + outFile.writeBytes(bytes) + onProgress?.invoke(index + 1, paths.size) + } + } catch (e: Exception) { + tmpDir.deleteRecursively() + if (e is CancellationException) throw e + return Result.failure(e) + } + + // Swap tmp into place. + rootDir.deleteRecursively() + if (!tmpDir.renameTo(rootDir)) { + tmpDir.deleteRecursively() + return Result.failure(IllegalStateException("Failed to finalize nsite folder")) + } + + val info = NsiteInfo( + address = address, + pubkey = pubkey, + kind = kind, + dTag = dTag, + displayName = displayName, + folderName = folderName, + aggregateHash = aggregateHashOf(manifest), + autoUpdate = autoUpdate, + lastChecked = TimeUtils.now(), + ) + upsert(info) + CustomWebSocketService.server?.startNsiteServer(info) + return Result.success(info) + } + + /** Downloads a blob, trying each server in order and verifying its sha256. */ + private fun downloadBlob(servers: List, sha256: String): ByteArray? { + val client = HttpClientManager.getHttpClient(Settings.proxyAllUrls) + for (server in servers) { + try { + val request = Request.Builder().url("$server/$sha256").get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use + val bytes = response.body.bytes() + if (sha256Hex(bytes).equals(sha256, ignoreCase = true)) { + return bytes + } else { + Log.w(TAG, "sha256 mismatch for $sha256 from $server") + } + } + } catch (e: Exception) { + Log.w(TAG, "blob download failed from $server: ${e.message}") + } + } + return null + } + + // endregion + + // region update check + + /** Re-fetches every installed manifest and returns the ones whose aggregate hash changed. */ + suspend fun checkForUpdates(): List { + val relays = discoveryRelays() + val updates = mutableListOf() + val current = Settings.nsites.toList() + for (nsite in current) { + val manifest = fetchManifest(nsite.pubkey, nsite.kind, nsite.dTag, relays) ?: continue + val newHash = aggregateHashOf(manifest) + // Record the check time regardless of whether content changed. + updateLastChecked(nsite.address, TimeUtils.now()) + if (newHash.isNotBlank() && newHash != nsite.aggregateHash) { + updates.add(NsiteUpdate(nsite, manifest, newHash)) + } + } + return updates + } + + fun setAutoUpdate(address: String, enabled: Boolean) { + val idx = Settings.nsites.indexOfFirst { it.address == address } + if (idx < 0) return + Settings.nsites[idx] = Settings.nsites[idx].copy(autoUpdate = enabled) + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + } + + fun delete(address: String) { + val nsite = Settings.nsites.firstOrNull { it.address == address } ?: return + Settings.nsites.removeAll { it.address == address } + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + File(Citrine.instance.filesDir, "nsites/${nsite.folderName}").deleteRecursively() + CustomWebSocketService.server?.stopNsiteServer(nsite.folderName) + } + + private fun upsert(info: NsiteInfo) { + val idx = Settings.nsites.indexOfFirst { it.address == info.address } + if (idx >= 0) Settings.nsites[idx] = info else Settings.nsites.add(info) + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + } + + private fun updateLastChecked(address: String, time: Long) { + val idx = Settings.nsites.indexOfFirst { it.address == address } + if (idx < 0) return + Settings.nsites[idx] = Settings.nsites[idx].copy(lastChecked = time) + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + } + + // endregion + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt b/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt index 1af7a86..5800412 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt @@ -45,6 +45,11 @@ import kotlinx.coroutines.withTimeoutOrNull private const val NOTIFICATION_THROTTLE_MS = 5_000L private const val ONE_WEEK_MILLIS = 7L * 24 * 60 * 60 * 1000 +private const val ONE_DAY_SECONDS = 24L * 60 * 60 + +// Notification id for "nsite update available" prompts. Ids 1/2/3 are already used by the +// foreground service, backup/download progress, and database-upgrade notifications. +const val NSITE_UPDATE_NOTIFICATION_ID = 4 class WebSocketServerService : Service() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -171,6 +176,32 @@ class WebSocketServerService : Service() { Log.e(Citrine.TAG, "Error backing up database", e) } } + + // Daily nsite update check. Gated by lastNsiteCheck like the weekly backup, + // and persisted before the async work so the next 100s tick doesn't re-trigger. + if (!Citrine.isImportingEvents && Settings.nsites.isNotEmpty()) { + val oneDayAgo = TimeUtils.now() - ONE_DAY_SECONDS + if (Settings.lastNsiteCheck < oneDayAgo) { + Settings.lastNsiteCheck = TimeUtils.now() + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + Citrine.instance.applicationScope.launch { + try { + val updates = NsiteManager.checkForUpdates() + updates.forEach { update -> + if (update.nsite.autoUpdate) { + NsiteManager.applyUpdate(update) + } else { + postNsiteUpdateNotification(update.nsite.address, update.nsite.displayName) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(Citrine.TAG, "Error checking nsite updates", e) + } + } + } + } } }, 0, @@ -406,6 +437,42 @@ class WebSocketServerService : Service() { ) } + private fun postNsiteUpdateNotification(address: String, displayName: String) { + val notificationManager = NotificationManagerCompat.from(Citrine.instance) + if (ActivityCompat.checkSelfPermission(Citrine.instance, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + val channel = NotificationChannelCompat.Builder( + "citrine", + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ) + .setName("Citrine") + .build() + notificationManager.createNotificationChannel(channel) + + val updateIntent = Intent(this, ClipboardReceiver::class.java) + updateIntent.putExtra("nsite_update", address) + val updatePendingIntent = PendingIntent.getBroadcast( + this, + // Unique requestCode per address so concurrent nsite notifications don't collide. + address.hashCode(), + updateIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + val notification = NotificationCompat.Builder(Citrine.instance, "citrine") + .setContentTitle(getString(R.string.app_name_release)) + .setContentText(getString(R.string.nsite_update_available_notification, displayName)) + .setSmallIcon(R.drawable.ic_notification) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .addAction(R.drawable.ic_launcher_background, getString(R.string.update_now), updatePendingIntent) + .build() + + notificationManager.notify(NSITE_UPDATE_NOTIFICATION_ID, notification) + } + private fun buildAggregatorSummary(status: AggregatorStatus): String { val phase = when (status.phase) { AggregatorPhase.IDLE -> getString(R.string.relay_aggregator_phase_idle) diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt b/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt index fa4f850..58f8df6 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt @@ -55,7 +55,9 @@ import com.greenart7c3.citrine.ui.navigation.Route import com.greenart7c3.citrine.ui.settings.AccessControlSettingsScreen import com.greenart7c3.citrine.ui.settings.AggregatorSettingsScreen import com.greenart7c3.citrine.ui.settings.BackupSettingsScreen +import com.greenart7c3.citrine.ui.settings.BrowseNsitesScreen import com.greenart7c3.citrine.ui.settings.NetworkSettingsScreen +import com.greenart7c3.citrine.ui.settings.NsiteRelaysSettingsScreen import com.greenart7c3.citrine.ui.settings.RelayInfoSettingsScreen import com.greenart7c3.citrine.ui.settings.RetentionSettingsScreen import com.greenart7c3.citrine.ui.settings.WebClientsSettingsScreen @@ -330,7 +332,19 @@ fun CitrineScaffold( BackupSettingsScreen(modifier = subScreenModifier, storageHelper = storageHelper, onApplyChanges = restartRelay) } composable(Route.WebClientsSettings.route) { - WebClientsSettingsScreen(modifier = subScreenModifier, storageHelper = storageHelper, onApplyChanges = restartRelay) + WebClientsSettingsScreen( + modifier = subScreenModifier, + storageHelper = storageHelper, + onApplyChanges = restartRelay, + onBrowseNsites = { navController.navigate(Route.BrowseNsites.route) }, + onNsiteRelays = { navController.navigate(Route.NsiteRelaysSettings.route) }, + ) + } + composable(Route.BrowseNsites.route) { + BrowseNsitesScreen(modifier = subScreenModifier) + } + composable(Route.NsiteRelaysSettings.route) { + NsiteRelaysSettingsScreen(modifier = subScreenModifier) } composable(Route.DatabaseInfo.route) { diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt new file mode 100644 index 0000000..b7fcdac --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt @@ -0,0 +1,61 @@ +package com.greenart7c3.citrine.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import coil3.request.ImageRequest + +/** + * Shows an nsite's icon (a Blossom URL or a local [java.io.File]) via Coil, falling back to a + * globe placeholder while loading or when no icon is available/decodable. + */ +@Composable +fun NsiteIcon( + model: Any?, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(8.dp) + Box( + modifier = modifier + .size(40.dp) + .clip(shape), + contentAlignment = Alignment.Center, + ) { + if (model == null) { + Placeholder() + } else { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(model) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp), + loading = { Placeholder() }, + error = { Placeholder() }, + ) + } + } +} + +@Composable +private fun Placeholder() { + Icon( + imageVector = Icons.Default.Public, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt new file mode 100644 index 0000000..5c2d329 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt @@ -0,0 +1,88 @@ +package com.greenart7c3.citrine.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.greenart7c3.citrine.R +import com.greenart7c3.citrine.server.NsiteInfo +import com.greenart7c3.citrine.server.Settings +import com.greenart7c3.citrine.service.NsiteManager + +@Composable +fun NsiteRow( + nsite: NsiteInfo, + updateAvailable: Boolean, + onOpen: () -> Unit, + onToggleAutoUpdate: (Boolean) -> Unit, + onUpdate: () -> Unit, + onDelete: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + NsiteIcon(model = remember(nsite.folderName) { NsiteManager.localIconFile(nsite.folderName) }) + Column(modifier = Modifier.weight(0.9f).padding(start = 12.dp)) { + Text( + nsite.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "${nsite.folderName}.localhost:${Settings.port}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = nsite.autoUpdate, onCheckedChange = onToggleAutoUpdate) + Text( + stringResource(R.string.auto_update), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + if (updateAvailable) { + TextButton(onClick = onUpdate) { + Text(stringResource(R.string.update_now)) + } + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt b/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt index 75372bf..dc5e296 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt @@ -97,6 +97,18 @@ sealed class Route( icon = Icons.Outlined.Settings, selectedIcon = Icons.Default.Settings, ) + + data object BrowseNsites : Route( + route = "Settings/BrowseNsites", + icon = Icons.Outlined.Settings, + selectedIcon = Icons.Default.Settings, + ) + + data object NsiteRelaysSettings : Route( + route = "Settings/NsiteRelays", + icon = Icons.Outlined.Settings, + selectedIcon = Icons.Default.Settings, + ) } val navigationItems = listOf(Route.Home, Route.Settings) @@ -109,4 +121,6 @@ val settingsSubRoutes = setOf( Route.RetentionSettings.route, Route.BackupSettings.route, Route.WebClientsSettings.route, + Route.BrowseNsites.route, + Route.NsiteRelaysSettings.route, ) diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt new file mode 100644 index 0000000..2aede98 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt @@ -0,0 +1,190 @@ +package com.greenart7c3.citrine.ui.settings + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.greenart7c3.citrine.R +import com.greenart7c3.citrine.service.NsiteManager +import com.greenart7c3.citrine.ui.components.NsiteIcon +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private data class InstallUiState( + val address: String, + val downloaded: Int, + val total: Int, +) + +@Composable +fun BrowseNsitesScreen( + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val state by NsiteManager.discoveryState.collectAsState() + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + var installing by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + NsiteManager.discover() + } + } + + Surface(modifier) { + when (val current = state) { + is NsiteManager.DiscoveryState.Loading, NsiteManager.DiscoveryState.Idle -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Text( + stringResource(R.string.discovering_nsites), + modifier = Modifier.padding(top = 16.dp), + ) + } + } + } + is NsiteManager.DiscoveryState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(current.message) + } + } + is NsiteManager.DiscoveryState.Loaded -> { + if (current.nsites.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.no_nsites_found)) + } + } else { + val query = searchQuery.text.trim() + val filtered = if (query.isEmpty()) { + current.nsites + } else { + current.nsites.filter { + it.displayName.contains(query, ignoreCase = true) || + it.authorName.contains(query, ignoreCase = true) || + it.address.contains(query, ignoreCase = true) + } + } + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.search_nsites)) }, + ) + if (filtered.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.no_nsites_found)) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(filtered) { nsite -> + val rowInstalling = installing?.takeIf { it.address == nsite.address } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !nsite.alreadyInstalled && installing == null) { + installing = InstallUiState(nsite.address, 0, 0) + scope.launch(Dispatchers.IO) { + val result = NsiteManager.install(nsite) { downloaded, total -> + installing = InstallUiState(nsite.address, downloaded, total) + } + withContext(Dispatchers.Main) { + installing = null + val message = if (result.isSuccess) { + context.getString(R.string.nsite_installed, nsite.displayName) + } else { + context.getString(R.string.nsite_install_failed, nsite.displayName) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + // Refresh the list so the installed item is now flagged. + NsiteManager.discover() + } + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (rowInstalling != null) { + Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { + if (rowInstalling.total > 0) { + CircularProgressIndicator( + progress = { rowInstalling.downloaded.toFloat() / rowInstalling.total }, + modifier = Modifier.size(28.dp), + ) + } else { + CircularProgressIndicator(modifier = Modifier.size(28.dp)) + } + } + } else { + NsiteIcon(model = nsite.iconUrl) + } + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + nsite.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + when { + rowInstalling != null && rowInstalling.total > 0 -> + stringResource(R.string.nsite_installing_progress, rowInstalling.downloaded, rowInstalling.total) + rowInstalling != null -> stringResource(R.string.installing_nsite, nsite.displayName) + nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) + nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) + else -> nsite.address + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + } + HorizontalDivider() + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt new file mode 100644 index 0000000..f17c945 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt @@ -0,0 +1,110 @@ +package com.greenart7c3.citrine.ui.settings + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.greenart7c3.citrine.R +import com.greenart7c3.citrine.server.Settings +import com.greenart7c3.citrine.service.LocalPreferences +import com.greenart7c3.citrine.ui.components.PubkeyInputRow +import com.greenart7c3.citrine.ui.components.PubkeyListItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun NsiteRelaysSettingsScreen( + modifier: Modifier = Modifier, +) { + var isLoading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + + Surface(modifier) { + var nsiteRelays by remember { mutableStateOf(Settings.nsiteRelays) } + var nsiteRelayInput by remember { mutableStateOf(TextFieldValue("")) } + + val applyChanges = { + scope.launch(Dispatchers.IO) { + isLoading = true + Settings.nsiteRelays = nsiteRelays + LocalPreferences.saveSettingsToEncryptedStorage(Settings, context) + delay(800) + isLoading = false + } + Unit + } + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.weight(1f)) { + item { + Text( + text = stringResource(R.string.nsite_relays_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + item { + PubkeyInputRow( + value = nsiteRelayInput, + onValueChange = { nsiteRelayInput = it }, + onPaste = { + scope.launch { + val text = clipboardManager.getClipEntry()?.clipData?.getItemAt(0)?.text?.toString() ?: return@launch + nsiteRelayInput = TextFieldValue(text) + } + }, + onAdd = { + val normalized = normalizeRelayInput(nsiteRelayInput.text) + if (normalized == null) { + Toast.makeText(context, context.getString(R.string.relay_aggregator_invalid_relay), Toast.LENGTH_SHORT).show() + return@PubkeyInputRow + } + nsiteRelays = nsiteRelays + normalized + nsiteRelayInput = TextFieldValue("") + }, + ) + } + item { + TextButton(onClick = { nsiteRelays = Settings.DEFAULT_NSITE_RELAYS }) { + Text(stringResource(R.string.reset_to_default)) + } + } + if (nsiteRelays.isEmpty()) { + item { + EmptyListHint(stringResource(R.string.nsite_relays_hint)) + } + } + items(nsiteRelays.toList()) { relay -> + PubkeyListItem( + text = relay, + onDelete = { nsiteRelays = nsiteRelays - relay }, + ) + } + } + + SettingsApplyBar(enabled = !isLoading, onApply = applyChanges) + } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt index 10eafa7..faf9ee0 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt @@ -2,11 +2,20 @@ package com.greenart7c3.citrine.ui.settings import android.content.Intent import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -17,12 +26,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.anggrayudi.storage.SimpleStorageHelper import com.greenart7c3.citrine.Citrine import com.greenart7c3.citrine.R import com.greenart7c3.citrine.server.Settings import com.greenart7c3.citrine.service.LocalPreferences +import com.greenart7c3.citrine.service.NsiteManager +import com.greenart7c3.citrine.ui.components.NsiteRow import com.greenart7c3.citrine.ui.components.WebAppInput import com.greenart7c3.citrine.ui.components.WebAppRow import kotlinx.coroutines.Dispatchers @@ -34,6 +46,8 @@ fun WebClientsSettingsScreen( modifier: Modifier = Modifier, storageHelper: SimpleStorageHelper, onApplyChanges: () -> Unit, + onBrowseNsites: () -> Unit, + onNsiteRelays: () -> Unit, ) { var isLoading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -42,6 +56,8 @@ fun WebClientsSettingsScreen( Surface(modifier) { var webPath by remember { mutableStateOf(TextFieldValue("")) } var webClients by remember { mutableStateOf>(Settings.webClients.toMap()) } + var nsites by remember { mutableStateOf(Settings.nsites.toList()) } + var updatesAvailable by remember { mutableStateOf>(emptySet()) } storageHelper.onFolderSelected = { _, folder -> val path = if (webPath.text.startsWith("/")) webPath.text else "/${webPath.text}" @@ -91,6 +107,67 @@ fun WebClientsSettingsScreen( item = item, ) } + + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + Text( + stringResource(R.string.nsites), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 4.dp), + ) + Button(onClick = onBrowseNsites) { + Text(stringResource(R.string.browse_nsites)) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { + scope.launch(Dispatchers.IO) { + val updates = NsiteManager.checkForUpdates() + updatesAvailable = updates.map { it.nsite.address }.toSet() + nsites = Settings.nsites.toList() + } + }, + ) { + Text(stringResource(R.string.check_for_updates)) + } + TextButton(onClick = onNsiteRelays) { + Text(stringResource(R.string.nsite_relays)) + } + } + } + + items(nsites) { nsite -> + NsiteRow( + nsite = nsite, + updateAvailable = updatesAvailable.contains(nsite.address), + onOpen = { + val browserIntent = Intent( + Intent.ACTION_VIEW, + "http://${nsite.folderName}.localhost:${Settings.port}".toUri(), + ) + browserIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + Citrine.instance.startActivity(browserIntent) + }, + onToggleAutoUpdate = { enabled -> + NsiteManager.setAutoUpdate(nsite.address, enabled) + nsites = Settings.nsites.toList() + }, + onUpdate = { + scope.launch(Dispatchers.IO) { + NsiteManager.applyUpdateByAddress(nsite.address) + updatesAvailable = updatesAvailable - nsite.address + nsites = Settings.nsites.toList() + } + }, + onDelete = { + NsiteManager.delete(nsite.address) + nsites = Settings.nsites.toList() + }, + ) + } } SettingsApplyBar(enabled = !isLoading, onApply = applyChanges) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9596da..171d430 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -170,6 +170,25 @@ Automatic database backups Web clients Host static web apps on the relay + Nsites + Browse nsites + Check for updates + Auto-update + Update now + Discovering nsites… + No nsites found + Search nsites + Installing %1$s… + Installing… %1$d/%2$d files + Installed %1$s + Failed to install %1$s + Already installed + by %1$s + Update available for %1$s + Nsite relays + Relays queried to discover nsites. Leave empty to use the default relays. + No relays added — using the default relays. + Reset to default Reset all settings All settings will be restored to their default values. This action cannot be undone. Settings reset to defaults diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc7a0d9..0f98e0c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ pagingCommon = "3.5.0" kmpTor = "2.6.0" kmpTorResource = "409.5.0" negentropy = "1.0.1" +coil = "3.1.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -72,6 +73,8 @@ kmp-tor-runtime-service = { module = "io.matthewnelson.kmp-tor:runtime-service", kmp-tor-runtime-service-ui = { module = "io.matthewnelson.kmp-tor:runtime-service-ui", version.ref = "kmpTor" } kmp-tor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" } negentropy = { module = "com.vitorpamplona.negentropy:kmp-negentropy-android", version.ref = "negentropy" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } [plugins] jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }