IrcViewModel.kt

Java Guest 7 Views Size: 286.17 KB Posted on: Mar 30, 26 @ 10:21 PM
  1. /*
  2. * HexDroidIRC - An IRC Client for Android
  3. * Copyright (C) 2026 boxlabs
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation, either version 3 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License
  16. * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  17. */
  18.  
  19. package com.boxlabs.hexdroid
  20. import android.annotation.SuppressLint
  21. import android.app.ActivityManager
  22. import android.content.Context
  23. import android.content.Intent
  24. import android.net.ConnectivityManager
  25. import android.net.NetworkCapabilities
  26. import android.net.Uri
  27. import android.opengl.EGL14
  28. import android.opengl.EGLConfig
  29. import android.opengl.GLES20
  30. import android.os.Build
  31. import android.os.StatFs
  32. import android.os.SystemClock
  33. import android.os.VibrationEffect
  34. import android.os.Vibrator
  35. import android.os.VibratorManager
  36. import android.provider.OpenableColumns
  37. import androidx.core.content.ContextCompat
  38. import androidx.core.content.FileProvider
  39. import androidx.lifecycle.ViewModel
  40. import androidx.lifecycle.viewModelScope
  41. import com.boxlabs.hexdroid.connection.ConnectionConstants
  42. import com.boxlabs.hexdroid.data.AutoJoinChannel
  43. import com.boxlabs.hexdroid.data.ChannelListEntry
  44. import com.boxlabs.hexdroid.data.NetworkProfile
  45. import com.boxlabs.hexdroid.data.SettingsRepository
  46. import com.boxlabs.hexdroid.data.ThemeMode
  47. import kotlinx.coroutines.CompletableDeferred
  48. import kotlinx.coroutines.Dispatchers
  49. import kotlinx.coroutines.Job
  50. import kotlinx.coroutines.TimeoutCancellationException
  51. import kotlinx.coroutines.delay
  52. import kotlinx.coroutines.flow.MutableStateFlow
  53. import kotlinx.coroutines.flow.StateFlow
  54. import kotlinx.coroutines.flow.update
  55. import kotlinx.coroutines.isActive
  56. import kotlinx.coroutines.launch
  57. import kotlinx.coroutines.sync.Mutex
  58. import kotlinx.coroutines.sync.withLock
  59. import kotlinx.coroutines.withContext
  60. import kotlinx.coroutines.withTimeout
  61. import java.io.BufferedReader
  62. import java.io.BufferedWriter
  63. import java.io.File
  64. import java.io.IOException
  65. import java.io.InputStreamReader
  66. import java.io.OutputStreamWriter
  67. import java.net.Socket
  68. import java.text.SimpleDateFormat
  69. import java.time.Instant
  70. import java.time.LocalDateTime
  71. import java.time.ZoneId
  72. import java.time.format.DateTimeFormatter
  73. import java.util.Date
  74. import java.util.Locale
  75. import java.util.concurrent.atomic.AtomicLong
  76. import kotlin.random.Random
  77.  
  78. enum class AppScreen { CHAT, LIST, SETTINGS, NETWORKS, NETWORK_EDIT, TRANSFERS, ABOUT, IGNORE }
  79.  
  80. /**
  81.  * UI-level message model.
  82.  *
  83.  * NOTE: [id] must be unique within a buffer list. Using timestamps alone can collide when multiple
  84.  * lines arrive within the same millisecond (common during connect/MOTD), which can crash Compose
  85.  * LazyColumn when keys are duplicated.
  86.  */
  87. data class UiMessage(
  88.     val id: Long,
  89.     val timeMs: Long,
  90.     val from: String?,
  91.     val text: String,
  92.     val isAction: Boolean = false,
  93.     /** True for MOTD body lines (372) so the UI can auto-size them to fit in one line. */
  94.     val isMotd: Boolean = false,
  95.     /**
  96.      * IRCv3 message-id (msgid tag). When non-null, used to deduplicate messages that arrive
  97.      * both via echo-message and chathistory replay, or after a bouncer reconnect.
  98.      */
  99.     val msgId: String? = null,
  100.     /**
  101.      * IRCv3 +reply / +draft/reply tag: the msgid of the message this is a reply to.
  102.      * When non-null, the UI shows a small quoted preview of the parent message above
  103.      * this one.
  104.      */
  105.     val replyToMsgId: String? = null,
  106. )
  107. data class UiBuffer(
  108.     val name: String,
  109.     val messages: List<UiMessage> = emptyList(),
  110.     val unread: Int = 0,
  111.     val highlights: Int = 0,
  112.     val topic: String? = null,
  113.     /** Channel mode string from 324 RPL_CHANNELMODEIS, e.g. "+nst" */
  114.     val modeString: String? = null,
  115.     /**
  116.      * ISO 8601 timestamp of the last message the user has read, as confirmed by the server
  117.      * via MARKREAD (draft/read-marker). Used to draw an unread separator in the chat view.
  118.      * Null when the server hasn't sent a read marker for this buffer.
  119.      */
  120.     val lastReadTimestamp: String? = null,
  121.     /**
  122.      * Set of nicks currently showing a typing indicator (draft/typing CAP).
  123.      * Cleared when the typing nick sends a message or emits "done" typing state.
  124.      */
  125.     val typingNicks: Set<String> = emptySet(),
  126.     /**
  127.      * O(1) msgId deduplication index.
  128.      *
  129.      * The previous implementation called `buf.messages.any { it.msgId == msgId }` on every
  130.      * incoming message, an O(n) linear scan that could visit up to 5,000 entries at max
  131.      * scrollback on a busy channel replaying history.
  132.      *
  133.      * This set mirrors the msgIds present in [messages] and is kept in sync with the
  134.      * scrollback trim in [append]: when [messages] is trimmed via `takeLast(maxLines)`, the
  135.      * set is rebuilt from the retained messages so evicted entries don't accumulate forever.
  136.      *
  137.      * Not part of equals/hashCode (it is derived from [messages]) and excluded from Compose
  138.      * stability checks — it is an internal performance cache, not observable UI state.
  139.      */
  140.     val seenMsgIds: Set<String> = emptySet()
  141. )
  142.  
  143. enum class FontChoice { OPEN_SANS, INTER, MONOSPACE, CUSTOM }
  144.  
  145. /** Default style applied to chat text (buffer + input). IRC formatting codes can still override this per-span. */
  146. enum class ChatFontStyle { REGULAR, BOLD, ITALIC, BOLD_ITALIC }
  147.  
  148. enum class VibrateIntensity { LOW, MEDIUM, HIGH }
  149.  
  150.  
  151. /** How we initiate DCC SEND connections. */
  152. enum class DccSendMode { AUTO, ACTIVE, PASSIVE }
  153.  
  154. data class UiSettings(
  155.     val themeMode: ThemeMode = ThemeMode.DARK,
  156.     val compactMode: Boolean = false,
  157.     val showTimestamps: Boolean = true,
  158.     val timestampFormat: String = "HH:mm:ss",
  159.     val fontScale: Float = 1.0f,
  160.     val fontChoice: FontChoice = FontChoice.OPEN_SANS,
  161.     val chatFontChoice: FontChoice = FontChoice.MONOSPACE,
  162.     val customFontPath: String? = null,
  163.     val customChatFontPath: String? = null,
  164.  
  165.     val chatFontStyle: ChatFontStyle = ChatFontStyle.REGULAR,
  166.     val showTopicBar: Boolean = true,
  167.     val hideMotdOnConnect: Boolean = false,
  168.     val hideJoinPartQuit: Boolean = false,
  169.     val hideTopicOnEntry: Boolean = false,
  170.     val defaultShowNickList: Boolean = true,
  171.     val defaultShowBufferList: Boolean = true,
  172.  
  173.     // Landscape split-pane fractions, updated by draggable handles.
  174.     val bufferPaneFracLandscape: Float = 0.22f,
  175.     val nickPaneFracLandscape: Float = 0.18f,
  176.  
  177.     val highlightOnNick: Boolean = true,
  178.     val extraHighlightWords: List<String> = emptyList(),
  179.  
  180.     val notificationsEnabled: Boolean = true,
  181.     val notifyOnHighlights: Boolean = true,
  182.     val notifyOnPrivateMessages: Boolean = true,
  183.     val showConnectionStatusNotification: Boolean = true,
  184.     val keepAliveInBackground: Boolean = true,
  185.     val autoReconnectEnabled: Boolean = true,
  186.     val autoReconnectDelaySec: Int = 10,
  187.     val autoConnectOnStartup: Boolean = false,
  188.     val playSoundOnHighlight: Boolean = false,
  189.     val vibrateOnHighlight: Boolean = false,
  190.     val vibrateIntensity: VibrateIntensity = VibrateIntensity.MEDIUM,
  191.  
  192.     val loggingEnabled: Boolean = false,
  193.     val logServerBuffer: Boolean = false,
  194.     val retentionDays: Int = 14,
  195.     val logFolderUri: String? = null,
  196.     val maxScrollbackLines: Int = 800,
  197.  
  198.     val ircHistoryLimit: Int = 50,
  199.     val ircHistoryCountsAsUnread: Boolean = false,
  200.     val ircHistoryTriggersNotifications: Boolean = false,
  201.  
  202.     val dccEnabled: Boolean = false,
  203.     val dccSendMode: DccSendMode = DccSendMode.AUTO,
  204.     val dccSecure: Boolean = false,      // SDCC: wrap transfers in TLS
  205.     val dccIncomingPortMin: Int = 5000,
  206.     val dccIncomingPortMax: Int = 5010,
  207.     val dccDownloadFolderUri: String? = null,
  208.  
  209.     val quitMessage: String = "HexDroid IRC - https://hexdroid.boxlabs.uk/",
  210.     val partMessage: String = "Leaving",
  211.  
  212.     val colorizeNicks: Boolean = true,
  213.     /**
  214.      * Custom colour for your own nick, stored as ARGB int (e.g. 0xFF_FF6600.toInt()).
  215.      * Null means "Auto" — let [NickColors.colorForNick] pick a colour from the hash,
  216.      * the same as any other nick.
  217.      */
  218.     val ownNickColorInt: Int? = null,
  219.  
  220.     val introTourSeenVersion: Int = 0,
  221.     val mircColorsEnabled: Boolean = true,
  222.     val ansiColorsEnabled: Boolean = true,
  223.  
  224.     val welcomeCompleted: Boolean = false,
  225.     val appLanguage: String? = null,
  226.     val portraitNicklistOverlay: Boolean = true,
  227.     val portraitNickPaneFrac: Float = 0.35f,
  228.  
  229.     /** Broadcast typing status to others (draft/typing CAP). Off by default for privacy. */
  230.     val sendTypingIndicator: Boolean = false,
  231.     /** Show typing indicators from others. Independent of sendTypingIndicator. */
  232.     val receiveTypingIndicator: Boolean = true,
  233.  
  234.     /** Show inline image and YouTube thumbnail previews in chat. */
  235.     val imagePreviewsEnabled: Boolean = false,
  236.     /** When true, only load previews on Wi-Fi to save mobile data. */
  237.     val imagePreviewsWifiOnly: Boolean = true,
  238. )
  239.  
  240. data class NetConnState(
  241.     val connected: Boolean = false,
  242.     val connecting: Boolean = false,
  243.     val status: String = "Disconnected",
  244.     val myNick: String = "me",
  245.     val lagMs: Long? = null,
  246.     /**
  247.      * Server-advertised *list* channel modes (from ISUPPORT CHANMODES group 1).
  248.      * Common: b,e,I,q. Defaults to a permissive set until ISUPPORT arrives.
  249.      */
  250.     val listModes: String = "bqeI",
  251.     /** True after 381 RPL_YOUREOPER is received for this connection */
  252.     val isIrcOper: Boolean = false,
  253.     /** True when the message-tags or draft/message-reactions cap is negotiated.
  254.      *  Used by ChatScreen to decide whether to offer emoji reactions. */
  255.     val hasReactionSupport: Boolean = false,
  256. )
  257.  
  258. data class BanEntry(
  259.     val mask: String,
  260.     val setBy: String? = null,
  261.     val setAtMs: Long? = null
  262. )
  263.  
  264. /**
  265.  * State for the /find search overlay in ChatScreen.
  266.  * [query] is the search term, [matchIds] are UiMessage.id values of all matches
  267.  * in chronological order, [currentIndex] is which one is focused (0 = oldest).
  268.  * [bufferKey] ties the overlay to the buffer where /find was invoked.
  269.  */
  270. data class FindOverlay(
  271.     val query: String,
  272.     val matchIds: List<Long>,
  273.     val currentIndex: Int = matchIds.lastIndex.coerceAtLeast(0),
  274.     val bufferKey: String,
  275. )
  276.  
  277. data class UiState(
  278.     val connected: Boolean = false,
  279.     val connecting: Boolean = false,
  280.     val status: String = "Disconnected",
  281.     val myNick: String = "me",
  282.  
  283.     val screen: AppScreen = AppScreen.NETWORKS,
  284.  
  285.     /**
  286.      * When the user taps a highlight/PM notification, the internal [UiMessage.id] of the
  287.      * triggering message is stored here so [ChatScreen] can scroll to and flash it.
  288.      * Cleared by [clearHighlightScroll] once the animation has been consumed.
  289.      */
  290.     /** Stable anchor for scrolling to a notified message. Set by handleIntent() when the user
  291.      *  taps a highlight/PM notification. Format: "msgid:<ircId>" or "ts:<sec>|<nick>|<text>". */
  292.     val pendingHighlightAnchor: String? = null,
  293.     /** Text shared from another app via ACTION_SEND. ChatScreen pre-fills the input with this
  294.      *  and clears it once consumed. */
  295.     val pendingShareText: String? = null,
  296.     /** Epoch-ms when pendingHighlightAnchor was last set; used to time-out the scroll attempt. */
  297.     val pendingHighlightSetAtMs: Long = 0L,
  298.     /** The buffer key that pendingHighlightAnchor belongs to. */
  299.     val pendingHighlightBufferKey: String? = null,
  300.  
  301.     /** Non-null while the /find overlay is open. */
  302.     val findOverlay: FindOverlay? = null,
  303.  
  304.     val connections: Map<String, NetConnState> = emptyMap(),
  305.     val buffers: Map<String, UiBuffer> = emptyMap(),
  306.     val selectedBuffer: String = "",
  307.     val nicklists: Map<String, List<String>> = emptyMap(),
  308.  
  309.     // Channel metadata
  310.     val banlists: Map<String, List<BanEntry>> = emptyMap(),
  311.     val banlistLoading: Map<String, Boolean> = emptyMap(),
  312.  
  313.     // Channel mode lists (common across ircu/unrealircd/nefarious/inspircd)
  314.     val quietlists: Map<String, List<BanEntry>> = emptyMap(),
  315.     val quietlistLoading: Map<String, Boolean> = emptyMap(),
  316.     val exceptlists: Map<String, List<BanEntry>> = emptyMap(),
  317.     val exceptlistLoading: Map<String, Boolean> = emptyMap(),
  318.     val invexlists: Map<String, List<BanEntry>> = emptyMap(),
  319.     val invexlistLoading: Map<String, Boolean> = emptyMap(),
  320.  
  321.     val showBufferList: Boolean = true,
  322.     val showNickList: Boolean = false,
  323.     val channelsOnly: Boolean = false,
  324.  
  325.     // /LIST UI (active network only)
  326.     val listInProgress: Boolean = false,
  327.     val channelDirectory: List<ChannelListEntry> = emptyList(),
  328.     val listFilter: String = "",
  329.     /** Sort order for the channel list: "size_desc", "size_asc", "name_asc", "name_desc". */
  330.     val listSort: String = "size_desc",
  331.  
  332.     val collapsedNetworkIds: Set<String> = emptySet(),
  333.     val settings: UiSettings = UiSettings(),
  334.     // Prevents a one-frame default-value flicker before DataStore loads.
  335.     val settingsLoaded: Boolean = false,
  336.     val networks: List<NetworkProfile> = emptyList(),
  337.     val activeNetworkId: String? = null,
  338.     val editingNetwork: NetworkProfile? = null,
  339.  
  340.     val networkEditError: String? = null,
  341.  
  342.     val plaintextWarningNetworkId: String? = null,
  343.     /** Non-null when a connect attempt was blocked because ACCESS_LOCAL_NETWORK is not granted (API 37+). */
  344.     val localNetworkWarningNetworkId: String? = null,
  345.  
  346.     val dccOffers: List<DccOffer> = emptyList(),
  347.     val dccChatOffers: List<DccChatOffer> = emptyList(),
  348.     val dccTransfers: List<DccTransferState> = emptyList(),
  349.  
  350.     val backupMessage: String? = null,
  351. )
  352.  
  353. class IrcViewModel(
  354.     private val repo: SettingsRepository,
  355.     context: Context
  356. ) : ViewModel() {
  357.     // ConcurrentHashMap used as a thread-safe set (touched from Main + IO).
  358.     private val scrollbackRequested: MutableSet<String> =
  359.         java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
  360.  
  361.     // Start time of scrollback loading, used to insert an end-of-scrollback marker before any live messages that arrived during load.
  362.     private val scrollbackLoadStartedAtMs: MutableMap<String, Long> =
  363.         java.util.concurrent.ConcurrentHashMap()
  364.  
  365.     @SuppressLint("StaticFieldLeak")
  366.     private val appContext: Context = context.applicationContext
  367.  
  368.     private val _state = MutableStateFlow(UiState())
  369.     val state: StateFlow<UiState> = _state
  370.  
  371.     /**
  372.      * Accumulation buffer for incoming 322 LIST replies.
  373.      *
  374.      * Large servers (e.g. Libera) send 10 000+ channel entries. If we update [_state] on
  375.      * every entry the entire UiState — including all message buffers — is copied O(n) times
  376.      * and the UI re-renders for each one. Instead we collect entries here and flush to
  377.      * [_state] in batches of [CHANNEL_LIST_BATCH_SIZE], with a final flush on 323 (ListEnd).
  378.      * The buffer is cleared on ListStart so back-to-back /list calls are safe.
  379.      */
  380.     private val _channelListBuffer = ArrayList<ChannelListEntry>()
  381.     private companion object {
  382.         const val CHANNEL_LIST_BATCH_SIZE = 200
  383.     }
  384.  
  385.     private data class NamesRequest(
  386.         val replyBufferKey: String,
  387.         val printToBuffer: Boolean = true,
  388.         val createdAtMs: Long = android.os.SystemClock.elapsedRealtime(),
  389.         val names: LinkedHashSet<String> = linkedSetOf()
  390.     )
  391.  
  392.    
  393.     data class NetSupport(
  394.         val chantypes: String = "#&",
  395.         val caseMapping: String = "rfc1459",
  396.         val prefixModes: String = "qaohv",
  397.         val prefixSymbols: String = "~&@%+",
  398.         val statusMsg: String? = null,
  399.         val chanModes: String? = null,
  400.         /**
  401.          * LINELEN from ISUPPORT 005: max bytes per IRC line including CRLF.
  402.          * Null = server didn't advertise it; treat as the RFC 1459 default of 512.
  403.          */
  404.         val linelen: Int? = null
  405.     )
  406.  
  407.  
  408.     private data class NetRuntime(
  409.         val netId: String,
  410.         val client: IrcClient,
  411.         var job: Job? = null,
  412.         var suppressMotd: Boolean = false,
  413.         var manualMotdAtMs: Long = 0L,
  414.         var myNick: String = client.config.nick,
  415.                 val namesRequests: MutableMap<String, NamesRequest> = mutableMapOf(),
  416.                 // Throttled to avoid spamming the server when the nicklist opens/closes rapidly.
  417.                 val lastNamesRefreshAtMs: MutableMap<String, Long> = java.util.concurrent.ConcurrentHashMap(),
  418.         var support: NetSupport = NetSupport(),
  419.         // Manually-joined channels not covered by autoJoin, rejoined on reconnect.
  420.         // Key = channel name (server casing), value = channel key or null.
  421.         val manuallyJoinedChannels: MutableMap<String, String?> = mutableMapOf()
  422.     )
  423.  
  424.     private val runtimes = mutableMapOf<String, NetRuntime>()
  425.  
  426.     private val desiredConnected = mutableSetOf<String>()
  427.     private var desiredNetworkIdsLoaded = false
  428.     private var desiredNetworkIdsApplied = false
  429.     private val autoReconnectJobs = mutableMapOf<String, Job>()
  430.     private val reconnectAttempts = mutableMapOf<String, Int>()
  431.     private val manualDisconnecting = mutableSetOf<String>()
  432.     private val noNetworkNotice = mutableSetOf<String>()
  433.  
  434.     // Flap detection: track timestamps (ms) of ping-timeout disconnects per network.
  435.     // If ≥ FLAP_THRESHOLD occur within FLAP_WINDOW_MS the connection is deemed unstable
  436.     // and auto-reconnect is suspended until the user manually reconnects.
  437.     private val pingTimeoutTimestamps = mutableMapOf<String, ArrayDeque<Long>>()
  438.  
  439.     // Flap-paused state is persisted via DataStore
  440.     // DataStore is the rest of the app's persistence layer and is immune to the data-loss
  441.     // bugs that SharedPreferences can exhibit under process death on certain OEM ROMs.
  442.     //
  443.     // In-memory set for fast synchronous checks during event handling; the DataStore copy
  444.     // is the durable source of truth that survives process kills.
  445.     private val flapPaused: MutableSet<String> = mutableSetOf()
  446.     private var flapPausedLoaded = false
  447.  
  448.     /** Hydrate the in-memory flapPaused set from DataStore (called once, lazily, on first use). */
  449.     private suspend fun ensureFlapPausedLoaded() {
  450.         if (flapPausedLoaded) return
  451.         flapPausedLoaded = true
  452.         val now = System.currentTimeMillis()
  453.         val stored = repo.readFlapPaused()
  454.         // Drop entries older than 2× the flap window so a week-old pause doesn't
  455.         // block reconnect forever after a stable period.
  456.         val active = stored.filter { (_, pausedAt) ->
  457.             pausedAt + ConnectionConstants.FLAP_WINDOW_MS * 2 > now
  458.         }
  459.         flapPaused.addAll(active.keys)
  460.         // Persist the cleaned-up map back so expired entries don't accumulate.
  461.         if (active.size != stored.size) {
  462.             val newMap = stored.filterKeys { it in flapPaused }
  463.             viewModelScope.launch(Dispatchers.IO) { runCatching { repo.writeFlapPaused(newMap) } }
  464.         }
  465.     }
  466.  
  467.     private fun markFlapPaused(netId: String) {
  468.         flapPaused.add(netId)
  469.         viewModelScope.launch(Dispatchers.IO) {
  470.             runCatching {
  471.                 val current = repo.readFlapPaused().toMutableMap()
  472.                 current[netId] = System.currentTimeMillis()
  473.                 repo.writeFlapPaused(current)
  474.             }
  475.         }
  476.     }
  477.  
  478.     private fun clearFlapPaused(netId: String) {
  479.         flapPaused.remove(netId)
  480.         viewModelScope.launch(Dispatchers.IO) {
  481.             runCatching {
  482.                 val current = repo.readFlapPaused().toMutableMap()
  483.                 current.remove(netId)
  484.                 repo.writeFlapPaused(current)
  485.             }
  486.         }
  487.     }
  488.  
  489.     // Not persisted; resets to all-expanded on process restart.
  490.     private val _collapsedNetworkIds = MutableStateFlow<Set<String>>(emptySet())
  491.     fun toggleNetworkExpanded(netId: String) {
  492.         _collapsedNetworkIds.update { current ->
  493.             if (current.contains(netId)) current - netId else current + netId
  494.         }
  495.     }
  496.  
  497.  
  498.     private fun launchExpandedNetworkIdsSync() {
  499.         viewModelScope.launch {
  500.             _collapsedNetworkIds.collect { ids ->
  501.                 _state.update { it.copy(collapsedNetworkIds = ids) }
  502.             }
  503.         }
  504.     }
  505.  
  506.     private val netOpLocks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
  507.     private fun netLock(netId: String): Mutex {
  508.         netOpLocks[netId]?.let { return it }
  509.         val created = Mutex()
  510.         val prev = netOpLocks.putIfAbsent(netId, created)
  511.         return prev ?: created
  512.     }
  513.     private suspend inline fun <T> withNetLock(netId: String, crossinline block: suspend () -> T): T {
  514.         return netLock(netId).withLock { block() }
  515.     }
  516.  
  517.     private fun hasInternetConnection(): Boolean {
  518.         val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
  519.         val net = cm.activeNetwork ?: return false
  520.         val caps = cm.getNetworkCapabilities(net) ?: return false
  521.         val hasTransport =
  522.             caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
  523.             caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
  524.             caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ||
  525.             caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
  526.         return hasTransport && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
  527.     }
  528.  
  529.  
  530.     /**
  531.      * Returns true when [host] resolves to a private/loopback/link-local address
  532.      * that requires ACCESS_LOCAL_NETWORK on Android 17+.
  533.      * Does a quick string-based check first (no DNS lookup) to avoid blocking the
  534.      * calling coroutine; unresolvable hostnames are assumed to be public.
  535.      */
  536.     private fun isLocalHost(host: String): Boolean {
  537.         val h = host.trim().lowercase()
  538.         // Loopback
  539.         if (h == "localhost" || h == "::1" || h.startsWith("127.")) return true
  540.         // Private IPv4 ranges: 10.x, 172.16–31.x, 192.168.x
  541.         if (h.startsWith("10.")) return true
  542.         if (h.startsWith("192.168.")) return true
  543.         if (h.startsWith("172.")) {
  544.             val second = h.split(".").getOrNull(1)?.toIntOrNull() ?: return false
  545.             if (second in 16..31) return true
  546.         }
  547.         // IPv6 link-local (fe80::) and unique-local (fc00::/7 = fc..–fd..)
  548.         if (h.startsWith("fe80:")) return true
  549.         if (h.startsWith("fc") || h.startsWith("fd")) return true
  550.         // Let DNS sort out anything else
  551.         return false
  552.     }
  553.  
  554.     /**
  555.      * Returns true when the app holds ACCESS_LOCAL_NETWORK (required on Android 17+).
  556.      * On earlier API levels the permission doesn't exist and this always returns true.
  557.      */
  558.     private fun hasLocalNetworkPermission(): Boolean {
  559.         if (android.os.Build.VERSION.SDK_INT < 37) return true
  560.         return android.content.pm.PackageManager.PERMISSION_GRANTED ==
  561.             androidx.core.content.ContextCompat.checkSelfPermission(
  562.                 appContext, "android.permission.ACCESS_LOCAL_NETWORK"
  563.             )
  564.     }
  565.  
  566.     private fun persistDesiredNetworkIds() {
  567.         val ids = desiredConnected.toSet()
  568.         viewModelScope.launch(Dispatchers.IO) {
  569.             runCatching { repo.setDesiredNetworkIds(ids) }
  570.         }
  571.     }
  572.  
  573.     private fun maybeRestoreDesiredConnections() {
  574.         if (desiredNetworkIdsApplied) return
  575.         if (!desiredNetworkIdsLoaded) return
  576.         val st = _state.value
  577.         if (st.networks.isEmpty()) return
  578.  
  579.         if (!st.settings.keepAliveInBackground) return
  580.  
  581.         desiredNetworkIdsApplied = true
  582.         val existing = st.networks.map { it.id }.toSet()
  583.         val targets = desiredConnected.filter { existing.contains(it) }.toList()
  584.         if (targets.isEmpty()) return
  585.         targets.forEach { id -> connectNetwork(id) }
  586.         val before = desiredConnected.size
  587.         desiredConnected.retainAll(existing)
  588.         if (desiredConnected.size != before) persistDesiredNetworkIds()
  589.     }
  590.  
  591.  
  592.     private fun vibrateForHighlight(intensity: VibrateIntensity) {
  593.         val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= 31) {
  594.             val vm = appContext.getSystemService(VibratorManager::class.java)
  595.             vm?.defaultVibrator
  596.         } else {
  597.             @Suppress("DEPRECATION")
  598.             appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
  599.         }
  600.  
  601.         if (vibrator == null || !vibrator.hasVibrator()) return
  602.  
  603.         val (durationMs, amplitude) = when (intensity) {
  604.             VibrateIntensity.LOW -> 25L to 80
  605.             VibrateIntensity.MEDIUM -> 40L to 160
  606.             VibrateIntensity.HIGH -> 70L to 255
  607.         }
  608.  
  609.         try {
  610.             if (Build.VERSION.SDK_INT >= 26) {
  611.                 vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
  612.             } else {
  613.                 @Suppress("DEPRECATION")
  614.                 vibrator.vibrate(durationMs)
  615.             }
  616.         } catch (_: Throwable) {
  617.             // Ignore vibration failures.
  618.         }
  619.     }
  620.  
  621.     // PART is sent when the user closes a buffer; the buffer is removed when we receive our own PART back.
  622.     private val pendingCloseAfterPart = mutableSetOf<String>()
  623.  
  624.     @Volatile private var appExitRequested: Boolean = false
  625.  
  626.     private fun isRecentEvent(timeMs: Long): Boolean {
  627.         val now = System.currentTimeMillis()
  628.         // 30s window should cover clock skew + batching without letting real playback mutate state.
  629.         return timeMs >= (now - 30_000L) && timeMs <= (now + 30_000L)
  630.     }
  631.  
  632.     // History events only affect live state if their timestamp is within 30s of now.
  633.     // Some bouncers mis-tag live messages as history, but those carry a recent @time.
  634.     private fun shouldAffectLiveState(isHistory: Boolean, timeMs: Long?): Boolean =
  635.         if (!isHistory) true else (timeMs != null && isRecentEvent(timeMs))
  636.  
  637.  
  638.     // Per-channel nick prefix tracking. Outer key = bufferKey, inner key = case-folded nick.
  639.     private val chanNickCase: MutableMap<String, MutableMap<String, String>> = mutableMapOf()
  640.     private val chanNickStatus: MutableMap<String, MutableMap<String, MutableSet<Char>>> = mutableMapOf()
  641.  
  642.     // away-notify state. Outer key = netId, inner key = case-folded nick, value = away message ("" if away with no message). Absent = present.
  643.     private val nickAwayState: MutableMap<String, MutableMap<String, String>> = mutableMapOf()
  644.  
  645.     private var autoConnectAttempted = false
  646.  
  647.     private val notifier = NotificationHelper(appContext)
  648.     private val logs = LogWriter(appContext)
  649.     private val dcc = DccManager(appContext)
  650.  
  651.     private data class DccChatSession(
  652.         val netId: String,
  653.         val peer: String,
  654.         val bufferKey: String,
  655.         val socket: Socket,
  656.         val writer: BufferedWriter,
  657.         val readJob: Job
  658.     )
  659.  
  660.     private val dccChatSessions: MutableMap<String, DccChatSession> = mutableMapOf()
  661.  
  662.     private data class PendingPassiveDccSend(
  663.         val target: String,
  664.         val filename: String,
  665.         val size: Long,
  666.         val reply: CompletableDeferred<DccOffer>
  667.     )
  668.  
  669.     private val pendingPassiveDccSends = mutableMapOf<Long, PendingPassiveDccSend>()
  670.  
  671.     /**
  672.      * Jobs for in-progress outgoing DCC sends, keyed by "$target/$filename".
  673.      * Stored so the user can cancel a send from the Transfers screen.
  674.      */
  675.     private val outgoingSendJobs = mutableMapOf<String, kotlinx.coroutines.Job>()
  676.  
  677.     private val nextUiMsgId = AtomicLong(1L)
  678.  
  679.     private val logTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
  680.  
  681.     private fun formatLogLine(timeMs: Long, from: String?, text: String, isAction: Boolean): String {
  682.         val ts = Instant.ofEpochMilli(timeMs).atZone(ZoneId.systemDefault()).format(logTimeFormatter)
  683.         val t = stripIrcFormatting(text)
  684.         val body = when {
  685.             from == null -> t
  686.             // *nick* text — asterisk-wrapped nick is unambiguous: server-status lines always
  687.             // use "* word …" (asterisk-space) and can never produce this pattern.
  688.             // Old logs used "* nick text"; the parser below handles both for backward compat.
  689.             isAction -> "*$from* $t"
  690.             else -> "<$from> $t"
  691.         }
  692.         return "$ts\t$body"
  693.     }
  694.  
  695.     private data class SentSig(val bufferKey: String, val text: String, val isAction: Boolean, val ts: Long)
  696.     private val pendingSendsByNet = mutableMapOf<String, ArrayDeque<SentSig>>()
  697.  
  698.     private fun bufKey(netId: String, bufferName: String): String = "$netId::$bufferName"
  699.  
  700.     // Case-fold aware lookup; merges duplicate buffers if the server changes name casing.
  701.     private fun resolveBufferKey(netId: String, bufferName: String): String {
  702.         val name = bufferName.trim().ifBlank { "*server*" }
  703.         val fold = casefoldText(netId, name)
  704.  
  705.         val st0 = _state.value
  706.         val candidates = st0.buffers.keys.filter { k ->
  707.             val (nid, bn) = splitKey(k)
  708.             nid == netId && casefoldText(netId, bn) == fold
  709.         }
  710.  
  711.         if (candidates.isEmpty()) return bufKey(netId, name)
  712.  
  713.         val chosen = when {
  714.             st0.selectedBuffer in candidates -> st0.selectedBuffer
  715.             else -> candidates.maxByOrNull { st0.buffers[it]?.messages?.size ?: 0 } ?: candidates.first()
  716.         }
  717.  
  718.         if (candidates.size > 1) {
  719.             mergeDuplicateBuffers(chosen, candidates.filter { it != chosen })
  720.         }
  721.  
  722.         return chosen
  723.     }
  724.  
  725.     private fun resolveIncomingBufferKey(netId: String, raw: String?): String {
  726.         val name = normalizeIncomingBufferName(netId, raw)
  727.         return resolveBufferKey(netId, name)
  728.     }
  729.  
  730.     private fun mergeDuplicateBuffers(keepKey: String, dropKeys: List<String>) {
  731.         if (dropKeys.isEmpty()) return
  732.         val st0 = _state.value
  733.         val keepBuf0 = st0.buffers[keepKey] ?: return
  734.  
  735.         val maxLines = st0.settings.maxScrollbackLines.coerceIn(100, 5000)
  736.  
  737.         var mergedMsgs = keepBuf0.messages
  738.         var unread = keepBuf0.unread
  739.         var highlights = keepBuf0.highlights
  740.         var topic = keepBuf0.topic
  741.  
  742.         for (k in dropKeys) {
  743.             val b = st0.buffers[k] ?: continue
  744.             if (b.messages.isNotEmpty()) mergedMsgs = mergedMsgs + b.messages
  745.             unread += b.unread
  746.             highlights += b.highlights
  747.             if (topic == null) topic = b.topic
  748.  
  749.             chanNickCase.remove(k)?.let { other ->
  750.                 val keep = chanNickCase.getOrPut(keepKey) { mutableMapOf() }
  751.                 keep.putAll(other)
  752.             }
  753.             chanNickStatus.remove(k)?.let { other ->
  754.                 val keep = chanNickStatus.getOrPut(keepKey) { mutableMapOf() }
  755.                 for ((fold, modes) in other) {
  756.                     val mm = keep.getOrPut(fold) { mutableSetOf() }
  757.                     mm.addAll(modes)
  758.                 }
  759.             }
  760.         }
  761.  
  762.         val merged = mergedMsgs
  763.             .distinctBy { it.id }
  764.             .sortedWith(compareBy<UiMessage> { it.timeMs }.thenBy { it.id })
  765.             .takeLast(maxLines)
  766.  
  767.         // Rebuild seenMsgIds from the retained messages so the O(1) dedup index stays
  768.         // consistent with the actual message list after a merge/rename operation.
  769.         val mergedSeenMsgIds: Set<String> = merged.mapNotNullTo(HashSet()) { it.msgId }
  770.  
  771.         val keepBuf = keepBuf0.copy(messages = merged, seenMsgIds = mergedSeenMsgIds, unread = unread, highlights = highlights, topic = topic)
  772.  
  773.         fun <T> adoptIfMissing(map: Map<String, T>): Map<String, T> {
  774.             var out = map
  775.             if (!out.containsKey(keepKey)) {
  776.                 val adopt = dropKeys.firstNotNullOfOrNull { out[it] }
  777.                 if (adopt != null) out = out + (keepKey to adopt)
  778.             }
  779.             for (k in dropKeys) out = out - k
  780.             return out
  781.         }
  782.  
  783.         if (pendingCloseAfterPart.any { it == keepKey || dropKeys.contains(it) }) {
  784.             pendingCloseAfterPart.removeAll(dropKeys.toSet())
  785.             pendingCloseAfterPart.add(keepKey)
  786.         }
  787.  
  788.         scrollbackRequested.removeAll(dropKeys.toSet())
  789.  
  790.         var newBuffers = st0.buffers + (keepKey to keepBuf)
  791.         for (k in dropKeys) newBuffers = newBuffers - k
  792.  
  793.         val newSelected = if (dropKeys.contains(st0.selectedBuffer)) keepKey else st0.selectedBuffer
  794.  
  795.         val next = st0.copy(
  796.             buffers = newBuffers,
  797.             selectedBuffer = newSelected,
  798.             nicklists = adoptIfMissing(st0.nicklists),
  799.             banlists = adoptIfMissing(st0.banlists),
  800.             banlistLoading = adoptIfMissing(st0.banlistLoading),
  801.             quietlists = adoptIfMissing(st0.quietlists),
  802.             quietlistLoading = adoptIfMissing(st0.quietlistLoading),
  803.             exceptlists = adoptIfMissing(st0.exceptlists),
  804.             exceptlistLoading = adoptIfMissing(st0.exceptlistLoading),
  805.             invexlists = adoptIfMissing(st0.invexlists),
  806.             invexlistLoading = adoptIfMissing(st0.invexlistLoading)
  807.         )
  808.         _state.value = syncActiveNetworkSummary(next)
  809.     }
  810.  
  811.     /**
  812.      * Pending-close tracking is keyed by the *exact* UI buffer key the user closed.
  813.      * Server replies may use a different case for the channel name, so match using CASEMAPPING-aware
  814.      * case-folding.
  815.      */
  816.     private fun popPendingCloseForChannel(netId: String, channel: String): String? {
  817.         val fold = casefoldText(netId, channel)
  818.         val match = pendingCloseAfterPart.firstOrNull { k ->
  819.             val (nid, bn) = splitKey(k)
  820.             nid == netId && casefoldText(netId, bn) == fold
  821.         }
  822.         if (match != null) pendingCloseAfterPart.remove(match)
  823.         return match
  824.     }
  825.  
  826.     private fun splitKey(key: String): Pair<String, String> {
  827.         val idx = key.indexOf("::")
  828.         return if (idx <= 0) ("unknown" to key) else (key.take(idx) to key.drop(idx + 2))
  829.     }
  830.  
  831.     private fun normalizeIncomingBufferName(netId: String, raw: String?): String {
  832.         val t = raw?.trim().orEmpty()
  833.         if (t.isBlank() || t == "?" || t == "*" || t.equals("AUTH", ignoreCase = true)) return "*server*"
  834.         return t
  835.     }
  836.  
  837.     private fun isChannelOnNet(netId: String, name: String): Boolean {
  838.         val chantypes = runtimes[netId]?.support?.chantypes ?: "#&"
  839.         return name.isNotBlank() && chantypes.any { name.startsWith(it) }
  840.     }
  841.  
  842.         private fun stripStatusMsgPrefix(netId: String, name: String): String {
  843.                 val support = runtimes[netId]?.support ?: return name
  844.                 val sm = support.statusMsg ?: return name
  845.                 val chantypes = support.chantypes
  846.                 return if (name.length >= 2 && sm.contains(name[0]) && chantypes.contains(name[1])) {
  847.                         name.substring(1)
  848.                 } else {
  849.                         name
  850.                 }
  851.         }
  852.  
  853.     /**
  854.      * Generic helper: initialises a mode-list buffer and marks it as loading.
  855.      * Replaces the four near-identical startBanList/startQuietList/startExceptList/startInvexList
  856.      * functions that were previously written out verbatim.
  857.      */
  858.     private fun startModeList(
  859.         netId: String,
  860.         channel: String,
  861.         getList: (UiState) -> Map<String, List<BanEntry>>,
  862.         getLoading: (UiState) -> Map<String, Boolean>,
  863.         setList: UiState.(Map<String, List<BanEntry>>) -> UiState,
  864.         setLoading: UiState.(Map<String, Boolean>) -> UiState
  865.     ) {
  866.         val key = resolveBufferKey(netId, channel)
  867.         ensureBuffer(key)
  868.         val st = _state.value
  869.         _state.value = syncActiveNetworkSummary(
  870.             st.setList(getList(st) + (key to emptyList()))
  871.                 .setLoading(getLoading(st) + (key to true))
  872.         )
  873.     }
  874.  
  875.     private fun startBanList(netId: String, channel: String) = startModeList(
  876.         netId, channel,
  877.         { it.banlists }, { it.banlistLoading },
  878.         { copy(banlists = it) }, { copy(banlistLoading = it) }
  879.     )
  880.  
  881.     private fun startQuietList(netId: String, channel: String) = startModeList(
  882.         netId, channel,
  883.         { it.quietlists }, { it.quietlistLoading },
  884.         { copy(quietlists = it) }, { copy(quietlistLoading = it) }
  885.     )
  886.  
  887.     private fun startExceptList(netId: String, channel: String) = startModeList(
  888.         netId, channel,
  889.         { it.exceptlists }, { it.exceptlistLoading },
  890.         { copy(exceptlists = it) }, { copy(exceptlistLoading = it) }
  891.     )
  892.  
  893.     private fun startInvexList(netId: String, channel: String) = startModeList(
  894.         netId, channel,
  895.         { it.invexlists }, { it.invexlistLoading },
  896.         { copy(invexlists = it) }, { copy(invexlistLoading = it) }
  897.     )
  898.  
  899.     private fun pendingDeque(netId: String): ArrayDeque<SentSig> =
  900.         pendingSendsByNet.getOrPut(netId) { ArrayDeque(32) }
  901.  
  902.     private fun recordLocalSend(netId: String, bufferKey: String, text: String, isAction: Boolean) {
  903.         val now = System.currentTimeMillis()
  904.         val dq = pendingDeque(netId)
  905.         dq.addLast(SentSig(bufferKey.lowercase(), text, isAction, now))
  906.         while (dq.size > 30) dq.removeFirst()
  907.     }
  908.  
  909.     private fun consumeEchoIfMatch(netId: String, bufferKey: String, text: String, isAction: Boolean): Boolean {
  910.         val now = System.currentTimeMillis()
  911.         val dq = pendingDeque(netId)
  912.         val bufKeyLower = bufferKey.lowercase()
  913.  
  914.         while (dq.isNotEmpty() && now - dq.first().ts > 8000) dq.removeFirst()
  915.  
  916.         // Last match wins so sending the same message twice dedupes correctly.
  917.         val matchIdx = dq.indexOfLast { it.bufferKey == bufKeyLower && it.text == text && it.isAction == isAction }
  918.         if (matchIdx < 0) return false
  919.  
  920.         dq.removeAt(matchIdx)
  921.         return true
  922.     }
  923.  
  924.     init {
  925.         launchExpandedNetworkIdsSync()
  926.         // Hydrate flap-paused state from DataStore before any connections start.
  927.         // This must be a suspend call, so we run it in viewModelScope. It completes almost
  928.         // instantly (single DataStore read) and sets flapPausedLoaded=true so the lazy guard
  929.         // in ensureFlapPausedLoaded() is a no-op on any subsequent call.
  930.         viewModelScope.launch { runCatching { ensureFlapPausedLoaded() } }
  931.         viewModelScope.launch {
  932.             repo.migrateLegacySecretsIfNeeded()
  933.             repo.settingsFlow.collect { s ->
  934.                 val st = _state.value
  935.                 val applyDefaults = st.settings == UiSettings()
  936.                 _state.value = st.copy(
  937.                     settings = s,
  938.                     settingsLoaded = true,
  939.                     // Only sync pane visibility to the new default if the user hasn't overridden it manually.
  940.                     showNickList = when {
  941.                         applyDefaults -> s.defaultShowNickList
  942.                         s.defaultShowNickList != st.settings.defaultShowNickList &&
  943.                             st.showNickList == st.settings.defaultShowNickList -> s.defaultShowNickList
  944.                         else -> st.showNickList
  945.                     },
  946.                     showBufferList = when {
  947.                         applyDefaults -> s.defaultShowBufferList
  948.                         s.defaultShowBufferList != st.settings.defaultShowBufferList &&
  949.                             st.showBufferList == st.settings.defaultShowBufferList -> s.defaultShowBufferList
  950.                         else -> st.showBufferList
  951.                     }
  952.                 )
  953.                 if (s.loggingEnabled) logs.purgeOlderThan(s.retentionDays, s.logFolderUri)
  954.                 maybeAutoConnect()
  955.                 maybeRestoreDesiredConnections()
  956.             }
  957.         }
  958.         viewModelScope.launch {
  959.             repo.networksFlow.collect { nets ->
  960.                 val st = _state.value
  961.                 val active = st.activeNetworkId ?: nets.firstOrNull()?.id
  962.                 val next = st.copy(networks = nets, activeNetworkId = active)
  963.  
  964.                 _state.value = next
  965.                 active?.let { ensureServerBuffer(it) }
  966.                 if (st.selectedBuffer.isBlank() && active != null) {
  967.                     _state.value = _state.value.copy(selectedBuffer = bufKey(active, "*server*"), screen = AppScreen.NETWORKS)
  968.                 }
  969.                 maybeAutoConnect()
  970.                 maybeRestoreDesiredConnections()
  971.             }
  972.         }
  973.         viewModelScope.launch {
  974.             repo.lastNetworkIdFlow.collect { last ->
  975.                 val st = _state.value
  976.                 if (!last.isNullOrBlank() && st.activeNetworkId == null) {
  977.                     _state.value = st.copy(activeNetworkId = last)
  978.                     ensureServerBuffer(last)
  979.                 }
  980.             }
  981.         }
  982.  
  983.         viewModelScope.launch {
  984.             repo.desiredNetworkIdsFlow.collect { ids ->
  985.                 desiredConnected.clear()
  986.                 desiredConnected.addAll(ids)
  987.                 desiredNetworkIdsLoaded = true
  988.                 refreshConnectionNotification()
  989.                 maybeRestoreDesiredConnections()
  990.             }
  991.         }
  992.        
  993.         registerNetworkCallback()
  994.        
  995.         notifier.ensureChannels()
  996.     }
  997.    
  998.     private var networkCallback: ConnectivityManager.NetworkCallback? = null
  999.    
  1000.     private fun registerNetworkCallback() {
  1001.         val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
  1002.        
  1003.         val cb = object : ConnectivityManager.NetworkCallback() {
  1004.             override fun onAvailable(network: android.net.Network) {
  1005.                 // Network became available - check if any desired connections need reconnecting
  1006.                 viewModelScope.launch {
  1007.                     delay(1000) // Brief delay to let the network stabilize
  1008.                     val st = _state.value
  1009.                     for (netId in desiredConnected) {
  1010.                         val conn = st.connections[netId]
  1011.                         if (conn?.connected != true && conn?.connecting != true) {
  1012.                             val serverKey = bufKey(netId, "*server*")
  1013.                             if (noNetworkNotice.remove(netId)) {
  1014.                                 append(serverKey, from = null, text = "*** Network available. Reconnecting…", doNotify = false)
  1015.                             }
  1016.                             connectNetwork(netId, force = true)
  1017.                         }
  1018.                     }
  1019.                 }
  1020.             }
  1021.            
  1022.             override fun onLost(network: android.net.Network) {
  1023.                 // Network lost - check if we still have connectivity via another network
  1024.                 viewModelScope.launch {
  1025.                     delay(500) // Brief delay to see if another network takes over
  1026.                     if (!hasInternetConnection()) {
  1027.                         // No connectivity at all - mark connections as waiting
  1028.                         val st = _state.value
  1029.                         for (netId in desiredConnected) {
  1030.                             val conn = st.connections[netId]
  1031.                             if (conn?.connected == true || conn?.connecting == true) {
  1032.                                 val serverKey = bufKey(netId, "*server*")
  1033.                                 append(serverKey, from = null, text = "*** Network lost. Waiting for connectivity…", doNotify = false)
  1034.                                 setNetConn(netId) { it.copy(status = "Waiting for network…") }
  1035.                             }
  1036.                         }
  1037.                     }
  1038.                 }
  1039.             }
  1040.            
  1041.             override fun onCapabilitiesChanged(network: android.net.Network, caps: NetworkCapabilities) {
  1042.                 // IrcCore's ping cycle handles stale sockets; nothing to do here.
  1043.             }
  1044.         }
  1045.         networkCallback = cb
  1046.         try {
  1047.             val request = android.net.NetworkRequest.Builder()
  1048.                 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
  1049.                 .build()
  1050.             cm.registerNetworkCallback(request, cb)
  1051.         } catch (e: Exception) {
  1052.             // Registration can fail on some OEM ROMs (e.g. missing permission, broken
  1053.             // ConnectivityManager implementation). Log it to every server buffer so the
  1054.             // user knows auto-reconnect on network change won't work.
  1055.             val msg = "*** Network callback registration failed: ${e.message ?: e.javaClass.simpleName} — " +
  1056.                 "auto-reconnect on network change may not work on this device"
  1057.             viewModelScope.launch {
  1058.                 for (netId in _state.value.networks.map { it.id }) {
  1059.                     append(bufKey(netId, "*server*"), from = null, text = msg, doNotify = false)
  1060.                 }
  1061.             }
  1062.         }
  1063.     }
  1064.  
  1065.     private fun maybeAutoConnect() {
  1066.         val st = _state.value
  1067.         if (autoConnectAttempted) return
  1068.         if (st.networks.isEmpty()) return
  1069.         autoConnectAttempted = true
  1070.         val targets = st.networks.filter { it.autoConnect }
  1071.         if (targets.isEmpty()) return
  1072.         targets.forEach { connectNetwork(it.id) }
  1073.     }
  1074.  
  1075.     fun setNetworkAutoConnect(netId: String, enabled: Boolean) {
  1076.         val n = _state.value.networks.firstOrNull { it.id == netId } ?: return
  1077.         viewModelScope.launch { repo.upsertNetwork(n.copy(autoConnect = enabled)) }
  1078.     }
  1079.  
  1080.     // ── IRC URI deep-link support ─────────────────────────────────────────────────
  1081.  
  1082.     private data class IrcUri(
  1083.         val host: String,
  1084.         val port: Int,
  1085.         val useTls: Boolean,
  1086.         val channels: List<String>,
  1087.         val channelKey: String? = null,
  1088.         val serverPassword: String? = null,
  1089.     )
  1090.  
  1091.     /**
  1092.      * Parses irc://, ircs://, and irc+ssl:// URIs into an [IrcUri].
  1093.      *
  1094.      * Handles every form seen in the wild:
  1095.      *   irc://host/channel           plain, port 6667
  1096.      *   irc://host:+6697/channel     TLS via +port convention (mIRC/ZNC) — note: Chrome
  1097.      *                                rejects this as an invalid URI; ircs:// or irc+ssl:// are
  1098.      *                                the browser-safe alternatives
  1099.      *   ircs://host:6697/channel     TLS via scheme (standard)
  1100.      *   irc+ssl://host:6697/channel  TLS via scheme (HexChat/irssi alternative)
  1101.      *   irc://host/#channel          # consumed as fragment by Uri; recovered
  1102.      *   irc://host/%23channel        percent-encoded; decoded automatically
  1103.      *   irc://host/chan?key=secret   channel key
  1104.      */
  1105.     private fun parseIrcUri(raw: String): IrcUri? {
  1106.         // Detect the +port TLS flag before Uri.parse() silently drops the '+'.
  1107.         val plusPortTls = Regex("""://[^/]*:\+\d+""").containsMatchIn(raw)
  1108.         // Normalise +port → plain port so android.net.Uri can parse correctly.
  1109.         val normalised = raw.replace(Regex("""(://[^/]*):(\+)(\d+)"""), "$1:$3")
  1110.  
  1111.         val uri = Uri.parse(normalised) ?: return null
  1112.         val scheme = uri.scheme?.lowercase() ?: return null
  1113.         if (scheme != "irc" && scheme != "ircs" && scheme != "irc+ssl") return null
  1114.  
  1115.         val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
  1116.         val useTls = scheme == "ircs" || scheme == "irc+ssl" || plusPortTls
  1117.         val port = uri.port.takeIf { it in 1..65535 } ?: if (useTls) 6697 else 6667
  1118.  
  1119.         // Channel in path segments (irc://host/channel or irc://host/%23channel).
  1120.         // If '#' was unencoded, android.net.Uri puts it in the fragment instead.
  1121.         val rawChannel = uri.pathSegments.firstOrNull()?.takeIf { it.isNotBlank() }
  1122.             ?: uri.fragment?.takeIf { it.isNotBlank() }
  1123.  
  1124.         val channels = rawChannel
  1125.             ?.split(",")
  1126.             ?.map { it.trim() }
  1127.             ?.filter { it.isNotEmpty() }
  1128.             ?.map { ch -> if (ch[0] in "#&+!") ch else "#$ch" }
  1129.             ?: emptyList()
  1130.  
  1131.         val channelKey = uri.getQueryParameter("key") ?: uri.getQueryParameter("pass")
  1132.         val serverPassword = uri.userInfo?.split(":", limit = 2)?.getOrNull(1)
  1133.             ?.takeIf { it.isNotEmpty() }
  1134.  
  1135.         return IrcUri(host, port, useTls, channels, channelKey, serverPassword)
  1136.     }
  1137.  
  1138.     /**
  1139.      * Opens (or creates) a network matching an IRC URI and navigates to it.
  1140.      *
  1141.      * Match priority:
  1142.      *  1. Existing network whose host + port + TLS match exactly → re-use.
  1143.      *  2. Existing network whose host matches (port/TLS differ) → re-use as-is.
  1144.      *  3. No match → create a new [NetworkProfile] pre-filled from the URI and
  1145.      *     open the Network Edit screen so the user can review before connecting.
  1146.      *
  1147.      * Channels from the URI are merged into the profile's autoJoin list if not
  1148.      * already present.  When an existing network is matched, the app connects
  1149.      * immediately and navigates to the first channel buffer.
  1150.      */
  1151.     private fun handleIrcUri(ircUri: IrcUri) {
  1152.         viewModelScope.launch {
  1153.             val st = _state.value
  1154.  
  1155.             // Inherit the nick from an existing network, or fall back to app default.
  1156.             val defaultNick = st.networks.firstOrNull()?.nick
  1157.                 ?: st.myNick.takeIf { it != "me" }
  1158.                 ?: "HexDroidUser"
  1159.  
  1160.             val existing = st.networks.firstOrNull { n ->
  1161.                 n.host.equals(ircUri.host, ignoreCase = true) &&
  1162.                 n.port == ircUri.port &&
  1163.                 n.useTls == ircUri.useTls
  1164.             } ?: st.networks.firstOrNull { n ->
  1165.                 n.host.equals(ircUri.host, ignoreCase = true)
  1166.             }
  1167.  
  1168.             val newAutoJoin = ircUri.channels.map { ch ->
  1169.                 AutoJoinChannel(ch, ircUri.channelKey)
  1170.             }
  1171.  
  1172.             if (existing != null) {
  1173.                 // Merge any new channels into the existing autoJoin list.
  1174.                 val mergedJoin = (existing.autoJoin + newAutoJoin)
  1175.                     .distinctBy { it.channel.lowercase() }
  1176.                 if (mergedJoin != existing.autoJoin) {
  1177.                     repo.updateNetworkProfile(existing.id) { it.copy(autoJoin = mergedJoin) }
  1178.                 }
  1179.                 setActiveNetwork(existing.id)
  1180.                 if (ircUri.channels.isNotEmpty()) {
  1181.                     openBuffer(bufKey(existing.id, ircUri.channels.first()))
  1182.                 } else {
  1183.                     _state.value = _state.value.copy(screen = AppScreen.CHAT)
  1184.                 }
  1185.             } else {
  1186.                 // New server — pre-fill from URI and open the edit screen for review.
  1187.                 val n = NetworkProfile(
  1188.                     id = "net_" + java.util.UUID.randomUUID().toString().replace("-", ""),
  1189.                     name = ircUri.host,
  1190.                     host = ircUri.host,
  1191.                     port = ircUri.port,
  1192.                     useTls = ircUri.useTls,
  1193.                     allowInvalidCerts = false,
  1194.                     nick = defaultNick,
  1195.                     altNick = "${defaultNick}_",
  1196.                     username = defaultNick.lowercase(),
  1197.                     realname = "HexDroid IRC",
  1198.                     serverPassword = ircUri.serverPassword,
  1199.                     saslEnabled = false,
  1200.                     saslMechanism = SaslMechanism.SCRAM_SHA_256,
  1201.                     caps = CapPrefs(),
  1202.                     autoJoin = newAutoJoin,
  1203.                 )
  1204.                 _state.value = _state.value.copy(
  1205.                     screen = AppScreen.NETWORK_EDIT,
  1206.                     editingNetwork = n,
  1207.                     networkEditError = null,
  1208.                 )
  1209.             }
  1210.         }
  1211.     }
  1212.  
  1213.     fun handleIntent(intent: Intent?) {
  1214.         if (intent == null) return
  1215.  
  1216.         // Handle text shared from another app (e.g. share a URL to paste into IRC).
  1217.         if (intent.action == Intent.ACTION_SEND &&
  1218.             intent.type?.startsWith("text/") == true) {
  1219.             val sharedText = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
  1220.                 ?.trim()?.takeIf { it.isNotBlank() }
  1221.             if (sharedText != null) {
  1222.                 // Navigate to chat screen if not already there.
  1223.                 if (_state.value.screen != AppScreen.CHAT) {
  1224.                     _state.value = _state.value.copy(screen = AppScreen.CHAT)
  1225.                 }
  1226.                 _state.value = _state.value.copy(pendingShareText = sharedText)
  1227.             }
  1228.             return
  1229.         }
  1230.  
  1231.         // Handle irc:// and ircs:// deep links before any notification extras.
  1232.         if (intent.action == Intent.ACTION_VIEW) {
  1233.             val uriString = intent.dataString
  1234.             if (!uriString.isNullOrBlank()) {
  1235.                 val scheme = Uri.parse(uriString)?.scheme?.lowercase()
  1236.                 if (scheme == "irc" || scheme == "ircs" || scheme == "irc+ssl") {
  1237.                     parseIrcUri(uriString)?.let { handleIrcUri(it) }
  1238.                     return
  1239.                 }
  1240.             }
  1241.         }
  1242.  
  1243.         val netId = intent.getStringExtra(NotificationHelper.EXTRA_NETWORK_ID)
  1244.         val buf = intent.getStringExtra(NotificationHelper.EXTRA_BUFFER)
  1245.         val action = intent.getStringExtra(NotificationHelper.EXTRA_ACTION)
  1246.         val highlightMsgId = intent.getLongExtra(NotificationHelper.EXTRA_MSG_ID, -1L)
  1247.             .takeIf { it >= 0L }
  1248.         val highlightAnchor = intent.getStringExtra(NotificationHelper.EXTRA_MSG_ANCHOR)
  1249.  
  1250.         if (action == NotificationHelper.ACTION_OPEN_TRANSFERS) {
  1251.             if (!netId.isNullOrBlank()) setActiveNetwork(netId)
  1252.             _state.value = _state.value.copy(screen = AppScreen.TRANSFERS)
  1253.             return
  1254.         }
  1255.  
  1256.         if (netId.isNullOrBlank() && buf.isNullOrBlank()) return
  1257.  
  1258.         if (!netId.isNullOrBlank()) setActiveNetwork(netId)
  1259.  
  1260.         val key = if (!netId.isNullOrBlank() && !buf.isNullOrBlank()) resolveBufferKey(netId, buf) else null
  1261.         if (key != null) openBuffer(key) else _state.value = _state.value.copy(screen = AppScreen.CHAT)
  1262.         if (highlightAnchor != null) {
  1263.             _state.value = _state.value.copy(
  1264.                 pendingHighlightAnchor = highlightAnchor,
  1265.                 pendingHighlightSetAtMs = System.currentTimeMillis(),
  1266.                 pendingHighlightBufferKey = key,
  1267.             )
  1268.         } else if (highlightMsgId != null) {
  1269.             // old notifications stored a Long id, keep compat for stale notifs
  1270.             _state.value = _state.value.copy(
  1271.                 pendingHighlightAnchor = "uiid:$highlightMsgId",
  1272.                 pendingHighlightSetAtMs = System.currentTimeMillis(),
  1273.                 pendingHighlightBufferKey = key,
  1274.             )
  1275.         }
  1276.     }
  1277.  
  1278.     /** Called by ChatScreen once the scroll-and-flash animation has run. */
  1279.     fun clearHighlightScroll() {
  1280.         _state.value = _state.value.copy(
  1281.             pendingHighlightAnchor = null,
  1282.             pendingHighlightBufferKey = null,
  1283.         )
  1284.     }
  1285.  
  1286.     fun consumeShareText() {
  1287.         _state.value = _state.value.copy(pendingShareText = null)
  1288.     }
  1289.  
  1290.     fun goTo(screen: AppScreen) {
  1291.         _state.value = _state.value.copy(screen = screen)
  1292.         if (screen == AppScreen.LIST) requestList()
  1293.     }
  1294.     fun backToChat() { _state.value = _state.value.copy(screen = AppScreen.CHAT) }
  1295.  
  1296.     fun openBuffer(key: String) {
  1297.         ensureBuffer(key)
  1298.         val (netId, _) = splitKey(key)
  1299.  
  1300.         val actualConnected = runtimes[netId]?.client?.isConnectedNow() == true
  1301.         val conn0 = _state.value.connections[netId]
  1302.         if (conn0?.connected == true && !actualConnected) {
  1303.             setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Disconnected") }
  1304.         } else if (conn0?.connected != true && actualConnected) {
  1305.             setNetConn(netId) { it.copy(connected = true, connecting = false, status = "Connected") }
  1306.         }
  1307.         if (_state.value.activeNetworkId != netId) setActiveNetwork(netId)
  1308.  
  1309.         // Collect MARKREAD params here so we can fire the coroutine after _state.update returns.
  1310.         // Launching inside update {} is wrong: the CAS loop can retry, sending MARKREAD multiple times.
  1311.         var markReadNet: String? = null
  1312.         var markReadName: String? = null
  1313.         var markReadTs: String? = null
  1314.  
  1315.         // One atomic update: stamp leaving buffer, anchor separator, switch buffer, clear badge.
  1316.         _state.update { st ->
  1317.             // Stamp the leaving buffer so new messages appear after the separator on return.
  1318.             val leaving = st.selectedBuffer
  1319.             var buffers = st.buffers
  1320.             if (leaving.isNotBlank() && leaving != key) {
  1321.                 val leavingBuf = buffers[leaving]
  1322.                 if (leavingBuf != null) {
  1323.                     val lastMsg = leavingBuf.messages.lastOrNull()
  1324.                     if (lastMsg != null) {
  1325.                         val ts = java.time.Instant.ofEpochMilli(lastMsg.timeMs + 1L).toString()
  1326.                         buffers = buffers + (leaving to leavingBuf.copy(lastReadTimestamp = ts))
  1327.  
  1328.                         val (leavingNet, leavingName) = splitKey(leaving)
  1329.                         val rt = runtimes[leavingNet]
  1330.                         if (rt != null && rt.client.hasCap("draft/read-marker")) {
  1331.                             markReadNet = leavingNet
  1332.                             markReadName = leavingName
  1333.                             markReadTs = ts
  1334.                         }
  1335.                     }
  1336.                 }
  1337.             }
  1338.  
  1339.             // Anchor a read marker if there isn't one yet, so the separator shows in the right place.
  1340.             val openingBuf = buffers[key]
  1341.             if (openingBuf != null && openingBuf.lastReadTimestamp == null && openingBuf.unread > 0) {
  1342.                 val msgs = openingBuf.messages
  1343.                 val firstUnreadPos = msgs.size - openingBuf.unread
  1344.                 val anchorMs = if (firstUnreadPos > 0) msgs[firstUnreadPos - 1].timeMs + 1L else 0L
  1345.                 val anchorTs = java.time.Instant.ofEpochMilli(anchorMs).toString()
  1346.                 buffers = buffers + (key to openingBuf.copy(lastReadTimestamp = anchorTs))
  1347.             }
  1348.  
  1349.             val afterOpen = buffers[key]
  1350.             if (afterOpen != null && (afterOpen.unread > 0 || afterOpen.highlights > 0)) {
  1351.                 buffers = buffers + (key to afterOpen.copy(unread = 0, highlights = 0))
  1352.             }
  1353.  
  1354.             st.copy(buffers = buffers, selectedBuffer = key, screen = AppScreen.CHAT)
  1355.         }
  1356.  
  1357.         // Send MARKREAD once, after the state update has settled.
  1358.         val mrNet = markReadNet; val mrName = markReadName; val mrTs = markReadTs
  1359.         if (mrNet != null && mrName != null && mrTs != null) {
  1360.             val rt = runtimes[mrNet]
  1361.             if (rt != null) {
  1362.                 viewModelScope.launch { runCatching { rt.client.sendRaw("MARKREAD $mrName timestamp=$mrTs") } }
  1363.             }
  1364.         }
  1365.         // The read marker for the opening buffer is stamped when the user reaches the bottom, not here.
  1366.     }
  1367.  
  1368.     /**
  1369.      * Set [UiBuffer.lastReadTimestamp] to the timestamp of the last message currently in [key],
  1370.      * then send MARKREAD to the server if the cap is available.
  1371.      * No-op if the buffer has no messages.
  1372.      */
  1373.     private fun stampReadMarker(key: String) {
  1374.         val st = _state.value
  1375.         val buf = st.buffers[key] ?: return
  1376.         val lastMsg = buf.messages.lastOrNull() ?: return
  1377.         // +1ms so the separator check (timeMs > lastReadMs) works even at second granularity.
  1378.         val ts = java.time.Instant.ofEpochMilli(lastMsg.timeMs + 1L).toString()
  1379.         _state.value = st.copy(buffers = st.buffers + (key to buf.copy(lastReadTimestamp = ts)))
  1380.         val (netId, bufferName) = splitKey(key)
  1381.         val rt = runtimes[netId] ?: return
  1382.         if (rt.client.hasCap("draft/read-marker")) {
  1383.             viewModelScope.launch { rt.client.sendRaw("MARKREAD $bufferName timestamp=$ts") }
  1384.         }
  1385.     }
  1386.  
  1387.     fun toggleBufferList() {
  1388.         val st = _state.value
  1389.         _state.value = st.copy(showBufferList = !st.showBufferList)
  1390.         viewModelScope.launch { runCatching { repo.updateSettings { it.copy(defaultShowBufferList = _state.value.showBufferList) } } }
  1391.     }
  1392.  
  1393.     fun toggleNickList() {
  1394.         val st = _state.value
  1395.         _state.value = st.copy(showNickList = !st.showNickList)
  1396.         viewModelScope.launch { runCatching { repo.updateSettings { it.copy(defaultShowNickList = _state.value.showNickList) } } }
  1397.     }
  1398.  
  1399.         fun refreshNicklistForSelectedBuffer(force: Boolean = false) {
  1400.                 val st = _state.value
  1401.                 val key = st.selectedBuffer
  1402.                 if (key.isBlank()) return
  1403.                 val (netId, rawBuf) = splitKey(key)
  1404.                 if (netId.isBlank()) return
  1405.                 val buf = stripStatusMsgPrefix(netId, rawBuf)
  1406.                 if (!isChannelOnNet(netId, buf)) return
  1407.                 val conn = st.connections[netId]
  1408.                 if (conn?.connected != true) return
  1409.                 val rt = runtimes[netId] ?: return
  1410.  
  1411.                 val fold = namesKeyFold(buf)
  1412.                 val now = SystemClock.elapsedRealtime()
  1413.                 val last = rt.lastNamesRefreshAtMs[fold] ?: 0L
  1414.                 if (!force && (now - last) < 5_000L) return
  1415.                 rt.lastNamesRefreshAtMs[fold] = now
  1416.  
  1417.                 rt.namesRequests[fold]?.let { inFlight ->
  1418.                         if ((now - inFlight.createdAtMs) < 12_000L) return
  1419.                         rt.namesRequests.remove(fold)
  1420.                 }
  1421.                 val replyKey = resolveBufferKey(netId, buf)
  1422.                 ensureBuffer(replyKey)
  1423.                 rt.namesRequests[fold] = NamesRequest(replyBufferKey = replyKey, printToBuffer = false)
  1424.                 viewModelScope.launch {
  1425.                         runCatching { rt.client.sendRaw("NAMES $buf") }
  1426.                 }
  1427.         }
  1428.  
  1429.     fun toggleChannelsOnly() { _state.value = _state.value.copy(channelsOnly = !_state.value.channelsOnly) }
  1430.     fun setListFilter(v: String) { _state.value = _state.value.copy(listFilter = v) }
  1431.     fun setListSort(v: String)   { _state.value = _state.value.copy(listSort = v) }
  1432.  
  1433.     fun closeFindOverlay() { _state.value = _state.value.copy(findOverlay = null) }
  1434.     fun findNavigate(delta: Int) {
  1435.         val ov = _state.value.findOverlay ?: return
  1436.         val newIdx = (ov.currentIndex + delta).coerceIn(0, ov.matchIds.lastIndex)
  1437.         _state.value = _state.value.copy(findOverlay = ov.copy(currentIndex = newIdx))
  1438.     }
  1439.  
  1440.     /**
  1441.      * Send a draft/message-reactions emoji reaction to [msgId] in the currently
  1442.      * selected buffer. No-op if the server doesn't support message-tags.
  1443.      */
  1444.     fun sendReaction(msgId: String, emoji: String, remove: Boolean = false) {
  1445.         val st = _state.value
  1446.         val key = st.selectedBuffer.takeIf { it.isNotBlank() } ?: return
  1447.         val (netId, bufferName) = splitKey(key)
  1448.         val rt = runtimes[netId] ?: return
  1449.         viewModelScope.launch { rt.client.sendReaction(bufferName, msgId, emoji, remove) }
  1450.     }
  1451.  
  1452.     /**
  1453.      * Sends [text] as a PRIVMSG to [buffer] on [networkId] without switching the active buffer.
  1454.      * Used by [NotificationReplyReceiver] for inline notification replies.
  1455.      *
  1456.      * If the server supports IRCv3 `+reply`/`draft/reply` and [msgId] is known, the reply tag
  1457.      * is attached so clients that understand threading show it as a reply.
  1458.      *
  1459.      * If the server does NOT support reply tags and [buffer] is a channel (not a PM), the
  1460.      * message is prefixed with `Nick: (quote) - ` so context isn't lost when replying
  1461.      * to an older message from the notification drawer.
  1462.      */
  1463.     fun sendToBuffer(
  1464.         networkId: String,
  1465.         buffer: String,
  1466.         text: String,
  1467.         from: String = "",
  1468.         originalText: String = "",
  1469.         msgId: String? = null,
  1470.     ) {
  1471.         viewModelScope.launch {
  1472.             val rt = runtimes[networkId] ?: return@launch
  1473.             val client = rt.client
  1474.             val myNick = _state.value.connections[networkId]?.myNick ?: _state.value.myNick
  1475.             val key = bufKey(networkId, buffer)
  1476.  
  1477.             // draft/reply is a client-only tag ("+draft/reply")
  1478.             // It's permitted whenever message-tags is negotiated. Some servers also
  1479.             // whitelist it explicitly via CLIENTTAGDENY=*,-draft/reply but we don't
  1480.             // need to parse that. message-tags is the correct gate.
  1481.             val hasReplyTagCap = client.hasCap("message-tags")
  1482.             val isChannel      = buffer.isNotEmpty() && buffer[0] in "#&+!"
  1483.  
  1484.             val outText = when {
  1485.                 // Server supports draft/reply AND we have a real server msgId: send tagged.
  1486.                 // We still go through privmsg() so echo-message dedup (recordLocalSend /
  1487.                 // consumeEchoIfMatch) works correctly;just prepend the tag via sendRaw
  1488.                 // and skip the privmsg call, but record the send for dedup ourselves.
  1489.                 hasReplyTagCap && msgId != null -> {
  1490.                     val sanitised = text.replace("\r", "").replace("\n", " ")
  1491.                     val labelTag = if (client.hasCap("echo-message") && client.hasCap("labeled-response"))
  1492.                         "@label=${java.util.UUID.randomUUID()} " else ""
  1493.                     client.sendRaw("${labelTag}@+draft/reply=$msgId PRIVMSG $buffer :$sanitised")
  1494.                     // Record so incoming echo-message is consumed rather than shown twice.
  1495.                     recordLocalSend(networkId, key, sanitised, isAction = false)
  1496.                     sanitised
  1497.                 }
  1498.                 // Channel without reply-tag support: prepend "Nick: (quote..) - reply"
  1499.                 isChannel && from.isNotBlank() && originalText.isNotBlank() -> {
  1500.                     val quote = originalText.take(60).let { if (originalText.length > 60) "$it…" else it }
  1501.                     "$from: ($quote) - $text"
  1502.                 }
  1503.                 // Channel, no quote available but sender known
  1504.                 isChannel && from.isNotBlank() -> "$from: $text"
  1505.                 // PM or no context
  1506.                 else -> text
  1507.             }
  1508.  
  1509.             // For all non-tag paths, use privmsg() which handles echo-message + label correctly.
  1510.             if (!(hasReplyTagCap && msgId != null)) {
  1511.                 client.privmsg(buffer, outText)
  1512.                 recordLocalSend(networkId, key, outText, isAction = false)
  1513.             }
  1514.  
  1515.             // Pass replyToMsgId so our own local echo shows the reply quote UI,
  1516.             // matching what other clients will see when the tagged message arrives.
  1517.             append(key, from = myNick, text = outText, isLocal = true,
  1518.                 replyToMsgId = if (hasReplyTagCap && msgId != null) msgId else null)
  1519.         }
  1520.     }
  1521.     fun updateSettings(update: UiSettings.() -> UiSettings) {
  1522.         // Apply immediately; DataStore confirms shortly after.
  1523.         val st = _state.value
  1524.         val next = st.settings.update()
  1525.         _state.value = st.copy(settings = next)
  1526.  
  1527.         viewModelScope.launch {
  1528.             runCatching { repo.updateSettings { it.update() } }
  1529.         }
  1530.     }
  1531.     fun setDccEnabled(enabled: Boolean) { updateSettings { copy(dccEnabled = enabled) } }
  1532.     fun setDccSendMode(mode: DccSendMode) { updateSettings { copy(dccSendMode = mode) } }
  1533.  
  1534.     fun setActiveNetwork(id: String) {
  1535.         val st = _state.value
  1536.         val next = st.copy(activeNetworkId = id)
  1537.         _state.value = syncActiveNetworkSummary(next)
  1538.         ensureServerBuffer(id)
  1539.         viewModelScope.launch(Dispatchers.IO) { runCatching { repo.setLastNetworkId(id) } }
  1540.     }
  1541.  
  1542.     private fun syncActiveNetworkSummary(st: UiState): UiState {
  1543.         val id = st.activeNetworkId ?: return st.copy(connected = false, connecting = false, status = "Disconnected", myNick = "me")
  1544.         val conn = st.connections[id] ?: NetConnState()
  1545.         return st.copy(connected = conn.connected, connecting = conn.connecting, status = conn.status, myNick = conn.myNick)
  1546.     }
  1547.  
  1548.     // Network
  1549.  
  1550.    
  1551.     /**
  1552.      * Restore the built-in AfterNET profile (used for support) if the user deleted it.
  1553.      * Safe to call multiple times; it no-ops if a profile named/id AfterNET already exists.
  1554.      */
  1555.     fun addAfterNetDefaults() {
  1556.         viewModelScope.launch {
  1557.             val st = _state.value
  1558.             val exists = st.networks.any { it.id.equals("AfterNET", ignoreCase = true) || it.name.equals("AfterNET", ignoreCase = true) }
  1559.             if (exists) return@launch
  1560.  
  1561.             val n = NetworkProfile(
  1562.                 id = "AfterNET",
  1563.                 name = "AfterNET",
  1564.                 host = "irc.afternet.org",
  1565.                 port = 6697,
  1566.                 useTls = true,
  1567.                 allowInvalidCerts = true,
  1568.                 nick = "HexDroidUser",
  1569.                 altNick = "HexDroidUser_",
  1570.                 username = "hexdroid",
  1571.                 realname = "HexDroid IRC for Android",
  1572.                 saslEnabled = false,
  1573.                 saslMechanism = SaslMechanism.SCRAM_SHA_256,
  1574.                 caps = CapPrefs(),
  1575.                 autoJoin = listOf(AutoJoinChannel("#HexDroid", null))
  1576.             )
  1577.  
  1578.             repo.upsertNetwork(n)
  1579.         }
  1580.     }
  1581.  
  1582.     /**
  1583.      * Update the nick (and altNick) on all default server profiles that still have
  1584.      * the factory-default "HexDroidUser" / "HexDroid" nick. Called from the welcome screen
  1585.      * so the user's chosen nickname is applied everywhere before they even connect.
  1586.      */
  1587.     fun updateAllDefaultNetworkNicks(nick: String) {
  1588.         viewModelScope.launch {
  1589.             val st = _state.value
  1590.             val defaultNicks = setOf("HexDroidUser", "HexDroid", "HexDroidUser_")
  1591.             for (net in st.networks) {
  1592.                 if (net.nick in defaultNicks) {
  1593.                     val updated = net.copy(
  1594.                         nick = nick,
  1595.                         altNick = "${nick}_",
  1596.                         username = nick.lowercase()
  1597.                     )
  1598.                     repo.upsertNetwork(updated)
  1599.                 }
  1600.             }
  1601.         }
  1602.     }
  1603.  
  1604.     /**
  1605.      * Called from the WelcomeScreen to persist the chosen language and nick, mark welcome as done,
  1606.      * and apply the nick to all default network profiles.
  1607.      */
  1608.     fun completeWelcome(languageCode: String, nick: String) {
  1609.         updateAllDefaultNetworkNicks(nick)
  1610.         updateSettings {
  1611.             copy(
  1612.                 welcomeCompleted = true,
  1613.                 appLanguage = languageCode
  1614.             )
  1615.         }
  1616.     }
  1617.  
  1618. fun startAddNetwork() {
  1619.         val n = NetworkProfile(
  1620.                 // Use UUID instead of currentTimeMillis() to avoid ID collisions when two
  1621.                 // networks are created within the same millisecond (e.g. from a backup restore).
  1622.                 id = "net_" + java.util.UUID.randomUUID().toString().replace("-", ""),
  1623.                 name = "New network",
  1624.                 host = "irc.example.org",
  1625.                 port = 6697,
  1626.                 useTls = true,
  1627.                 allowInvalidCerts = false,
  1628.                 nick = "HexDroidUser",
  1629.                 altNick = "HexDroidUser_",
  1630.                 username = "hexdroid",
  1631.                 realname = "HexDroid IRC",
  1632.                 saslEnabled = false,
  1633.                 saslMechanism = SaslMechanism.SCRAM_SHA_256,
  1634.                 caps = CapPrefs(),
  1635.                 autoJoin = emptyList()
  1636.         )
  1637.         _state.value = _state.value.copy(screen = AppScreen.NETWORK_EDIT, editingNetwork = n, networkEditError = null)
  1638.     }
  1639.  
  1640.     fun startEditNetwork(id: String) {
  1641.         viewModelScope.launch {
  1642.             val st0 = _state.value
  1643.             val n = st0.networks.firstOrNull { it.id == id } ?: return@launch
  1644.  
  1645.             // Passwords/secrets are stored in SecretStore (Android Keystore). Load them for the edit form only.
  1646.             val serverPass = repo.secretStore.getServerPassword(id)
  1647.             val saslPass = repo.secretStore.getSaslPassword(id)
  1648.  
  1649.             val withSecrets = n.copy(
  1650.                 serverPassword = serverPass,
  1651.                 saslPassword = saslPass
  1652.             )
  1653.  
  1654.             val st1 = _state.value
  1655.             _state.value = st1.copy(
  1656.                 screen = AppScreen.NETWORK_EDIT,
  1657.                 editingNetwork = withSecrets,
  1658.                 networkEditError = null
  1659.             )
  1660.         }
  1661.     }
  1662.  
  1663.     fun cancelEditNetwork() { _state.value = _state.value.copy(screen = AppScreen.NETWORKS, editingNetwork = null, networkEditError = null) }
  1664.  
  1665.     fun dismissLocalNetworkWarning() {
  1666.         _state.value = _state.value.copy(localNetworkWarningNetworkId = null)
  1667.     }
  1668.  
  1669.     /** Called after the user has granted ACCESS_LOCAL_NETWORK — retry the connection. */
  1670.     fun retryAfterLocalNetworkPermission(netId: String) {
  1671.         _state.value = _state.value.copy(localNetworkWarningNetworkId = null)
  1672.         connectNetwork(netId)
  1673.     }
  1674.  
  1675.     fun dismissPlaintextWarning() {
  1676.         _state.value = _state.value.copy(plaintextWarningNetworkId = null)
  1677.     }
  1678.  
  1679.     fun allowPlaintextAndConnect(netId: String) {
  1680.         viewModelScope.launch {
  1681.             val st = _state.value
  1682.             val n = st.networks.firstOrNull { it.id == netId } ?: return@launch
  1683.             val updated = n.copy(allowInsecurePlaintext = true)
  1684.             repo.upsertNetwork(updated)
  1685.             _state.value = _state.value.copy(
  1686.                 networks = st.networks.map { if (it.id == netId) updated else it },
  1687.                 plaintextWarningNetworkId = null
  1688.             )
  1689.             connectNetwork(netId, force = true)
  1690.         }
  1691.     }
  1692.  
  1693.     fun saveEditingNetwork(profile: NetworkProfile, clientCertDraft: ClientCertDraft?, removeClientCert: Boolean) {
  1694.         viewModelScope.launch {
  1695.             _state.value = _state.value.copy(networkEditError = null)
  1696.             if (profile.saslEnabled) {
  1697.                 val p = profile.saslPassword?.trim()
  1698.                 if (!p.isNullOrBlank()) {
  1699.                     repo.secretStore.setSaslPassword(profile.id, p)
  1700.                 }
  1701.             } else {
  1702.                 repo.secretStore.clearSaslPassword(profile.id)
  1703.             }
  1704.  
  1705.             val sp = profile.serverPassword?.trim()
  1706.             if (!sp.isNullOrBlank()) {
  1707.                 repo.secretStore.setServerPassword(profile.id, sp)
  1708.             } else {
  1709.                 // Field was cleared — remove any previously stored password so the
  1710.                 // old value isn't silently reused on the next connection attempt.
  1711.                 repo.secretStore.clearServerPassword(profile.id)
  1712.             }
  1713.  
  1714.             var updated = profile.copy(
  1715.                 saslPassword = null,
  1716.                 serverPassword = null,
  1717.                 tlsClientCertLabel = profile.tlsClientCertLabel
  1718.             )
  1719.  
  1720.             if (removeClientCert) {
  1721.                 repo.secretStore.removeClientCert(profile.id, profile.tlsClientCertId)
  1722.                 updated = updated.copy(tlsClientCertId = null, tlsClientCertLabel = null)
  1723.             }
  1724.  
  1725.             if (clientCertDraft != null) {
  1726.                 repo.secretStore.removeClientCert(profile.id, updated.tlsClientCertId)
  1727.                 val stored = try {
  1728.                     repo.secretStore.importClientCert(profile.id, clientCertDraft)
  1729.                 } catch (t: Throwable) {
  1730.                     _state.value = _state.value.copy(
  1731.                         screen = AppScreen.NETWORK_EDIT,
  1732.                         editingNetwork = profile,
  1733.                         networkEditError = t.message ?: "Failed to import certificate"
  1734.                     )
  1735.                     return@launch
  1736.                 }
  1737.                 updated = updated.copy(
  1738.                     tlsClientCertId = stored.certId,
  1739.                     tlsClientCertLabel = stored.label
  1740.                 )
  1741.             }
  1742.  
  1743.             repo.upsertNetwork(updated)
  1744.             repo.setLastNetworkId(updated.id)
  1745.  
  1746.             _state.value = _state.value.copy(
  1747.                 screen = AppScreen.NETWORKS,
  1748.                 editingNetwork = null,
  1749.                 activeNetworkId = updated.id,
  1750.                 networkEditError = null
  1751.             )
  1752.         }
  1753.     }
  1754.  
  1755.     /**
  1756.      * Purges all per-network in-memory maps for [netId].
  1757.      *
  1758.      * Called on disconnect, network deletion, and any hard reset so that:
  1759.      * - chanNickCase / chanNickStatus (per-channel nick tracking)
  1760.      * - nickAwayState (away status per nick)
  1761.      * - pendingSendsByNet (echo-message dedup queue)
  1762.      * - pendingCloseAfterPart (channels awaiting close after /part)
  1763.      * - receivedTypingExpiryJobs (typing indicator timers)
  1764.      * - reconnectAttempts / autoReconnectJobs
  1765.      * ...do not accumulate entries for networks that no longer exist.
  1766.      */
  1767.     private fun cleanupNetworkMaps(netId: String) {
  1768.         // Per-channel nick maps
  1769.         val chanPrefix = "$netId::"
  1770.         chanNickCase.keys.filter   { it.startsWith(chanPrefix) }.forEach { chanNickCase.remove(it) }
  1771.         chanNickStatus.keys.filter { it.startsWith(chanPrefix) }.forEach { chanNickStatus.remove(it) }
  1772.         // Away state
  1773.         nickAwayState.remove(netId)
  1774.         // Echo dedup queue
  1775.         pendingSendsByNet.remove(netId)
  1776.         // Pending close-after-part
  1777.         pendingCloseAfterPart.removeAll(pendingCloseAfterPart.filter { it.startsWith(chanPrefix) }.toSet())
  1778.         // Typing expiry jobs: cancel and remove all for this network
  1779.         val typingKeys = receivedTypingExpiryJobs.keys.filter { it.startsWith(chanPrefix) }
  1780.         typingKeys.forEach { receivedTypingExpiryJobs.remove(it)?.cancel() }
  1781.         // Reconnect state
  1782.         reconnectAttempts.remove(netId)
  1783.         autoReconnectJobs.remove(netId)?.cancel()
  1784.         noNetworkNotice.remove(netId)
  1785.         // Flap detection (NOT cleared here - we want to preserve across reconnect cycles
  1786.         // so flapping doesn't reset just because cleanupNetworkMaps was called.
  1787.         // Cleared explicitly in reconnectNetwork() when the user manually reconnects.)
  1788.     }
  1789.  
  1790.     fun deleteNetwork(id: String) {
  1791.         viewModelScope.launch {
  1792.             repo.deleteNetwork(id)
  1793.             repo.secretStore.clearSaslPassword(id)
  1794.             repo.secretStore.clearServerPassword(id)
  1795.         }
  1796.         disconnectNetwork(id)
  1797.         cleanupNetworkMaps(id)
  1798.         val st = _state.value
  1799.         if (st.activeNetworkId == id) _state.value = syncActiveNetworkSummary(st.copy(activeNetworkId = st.networks.firstOrNull { it.id != id }?.id))
  1800.     }
  1801.  
  1802.     /** Clear the transient backup/restore result message (called after the UI has shown it). */
  1803.     fun clearBackupMessage() {
  1804.         _state.update { it.copy(backupMessage = null) }
  1805.     }
  1806.  
  1807.     /**
  1808.      * Write a backup of current settings and networks to [uri] (obtained from
  1809.      * ACTION_CREATE_DOCUMENT).  The URI must be writable.
  1810.      *
  1811.      * Passwords and TLS client certificates are excluded - they are tied to device-specific
  1812.      * Android Keystore keys and cannot be transferred.
  1813.      */
  1814.     fun exportBackup(uri: android.net.Uri) {
  1815.         val st = _state.value
  1816.         viewModelScope.launch(Dispatchers.IO) {
  1817.             val result = runCatching {
  1818.                 val json = repo.exportBackupJson(st.networks, st.settings)
  1819.                 appContext.contentResolver.openOutputStream(uri)?.use { out ->
  1820.                     out.write(json.toByteArray(Charsets.UTF_8))
  1821.                 } ?: throw java.io.IOException("Unable to open output stream for backup file")
  1822.                 "Backup saved successfully.\nNote: passwords and certificates are not included."
  1823.             }
  1824.             val msg = result.getOrElse { e -> "Backup failed: ${e.message}" }
  1825.             _state.update { it.copy(backupMessage = msg) }
  1826.         }
  1827.     }
  1828.  
  1829.     /**
  1830.      * Read a backup file from [uri] (obtained from ACTION_OPEN_DOCUMENT) and restore
  1831.      * settings and networks.  Existing networks are replaced.
  1832.      *
  1833.      * On success, UI settings take effect on next DataStore emission.
  1834.      * Passwords are not restored and will need to be re-entered.
  1835.      */
  1836.     fun importBackup(uri: android.net.Uri) {
  1837.         viewModelScope.launch(Dispatchers.IO) {
  1838.             val result = runCatching {
  1839.                 val json = appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() }
  1840.                     ?.toString(Charsets.UTF_8)
  1841.                     ?: throw java.io.IOException("Unable to read backup file")
  1842.                 repo.importBackup(json)
  1843.                 "Backup restored successfully.\nPasswords were not restored — please re-enter them in each network's settings."
  1844.             }
  1845.             val msg = result.getOrElse { e -> "Restore failed: ${e.message}" }
  1846.             _state.update { it.copy(backupMessage = msg) }
  1847.         }
  1848.     }
  1849.  
  1850.  
  1851.     /**
  1852.      * Reorder the network list after a drag-and-drop gesture.
  1853.      * [fromIndex] and [toIndex] are indices into the currently-displayed sorted list.
  1854.      * sortOrder values are reassigned sequentially so they remain stable after serialisation.
  1855.      */
  1856.     fun reorderNetworks(fromIndex: Int, toIndex: Int) {
  1857.         viewModelScope.launch {
  1858.             val sorted = _state.value.networks
  1859.                 .sortedWith(compareBy({ !it.isFavourite }, { it.sortOrder }, { it.name }))
  1860.                 .toMutableList()
  1861.             if (fromIndex !in sorted.indices || toIndex !in sorted.indices) return@launch
  1862.             val item = sorted.removeAt(fromIndex)
  1863.             sorted.add(toIndex, item)
  1864.             val updated = sorted.mapIndexed { i, n -> n.copy(sortOrder = i) }
  1865.             // One write to avoid race conditions when upsertNetwork() is called in a loop.
  1866.             repo.saveNetworks(updated)
  1867.         }
  1868.     }
  1869.  
  1870.     /** Toggle the favourite flag for a network. Favourites sort before non-favourites. */
  1871.     fun toggleFavourite(netId: String) {
  1872.         viewModelScope.launch {
  1873.             val profile = _state.value.networks.firstOrNull { it.id == netId } ?: return@launch
  1874.             repo.upsertNetwork(profile.copy(isFavourite = !profile.isFavourite))
  1875.         }
  1876.     }
  1877.  
  1878.     fun connectNetwork(netId: String, force: Boolean = false) {
  1879.         viewModelScope.launch {
  1880.             // Ensure flap state is loaded from DataStore before checking it.
  1881.             // In the normal case this is a no-op (init already loaded it); this guards
  1882.             // the race where a connect is requested before the init coroutine completes.
  1883.             ensureFlapPausedLoaded()
  1884.             withNetLock(netId) {
  1885.                 connectNetworkInternal(netId, force)
  1886.             }
  1887.         }
  1888.     }
  1889.  
  1890.     private fun connectNetworkInternal(netId: String, force: Boolean = false) {
  1891.         val st = _state.value
  1892.         val conn = st.connections[netId]
  1893.         if (!force && (conn?.connected == true || conn?.connecting == true)) return
  1894.  
  1895.         val profilePre = st.networks.firstOrNull { it.id == netId }
  1896.         if (profilePre != null && !profilePre.useTls && !profilePre.allowInsecurePlaintext) {
  1897.             val removedDesired = desiredConnected.remove(netId)
  1898.             if (removedDesired) persistDesiredNetworkIds()
  1899.             manualDisconnecting.remove(netId)
  1900.             autoReconnectJobs.remove(netId)?.cancel()
  1901.             setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Plaintext disabled") }
  1902.             if (_state.value.activeNetworkId == netId) clearConnectionNotification()
  1903.             _state.value = _state.value.copy(plaintextWarningNetworkId = netId)
  1904.             return
  1905.         }
  1906.  
  1907.         // Android 17+: connecting to a local IP requires ACCESS_LOCAL_NETWORK at runtime.
  1908.         if (profilePre != null && isLocalHost(profilePre.host) && !hasLocalNetworkPermission()) {
  1909.             val removedDesired2 = desiredConnected.remove(netId)
  1910.             if (removedDesired2) persistDesiredNetworkIds()
  1911.             manualDisconnecting.remove(netId)
  1912.             autoReconnectJobs.remove(netId)?.cancel()
  1913.             setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Local network permission required") }
  1914.             if (_state.value.activeNetworkId == netId) clearConnectionNotification()
  1915.             _state.value = _state.value.copy(localNetworkWarningNetworkId = netId)
  1916.             return
  1917.         }
  1918.  
  1919.         val addedDesired = desiredConnected.add(netId)
  1920.         if (addedDesired) persistDesiredNetworkIds()
  1921.         manualDisconnecting.remove(netId)
  1922.         autoReconnectJobs.remove(netId)?.cancel()
  1923.  
  1924.         val existing = runtimes.remove(netId)
  1925.         if (existing != null) {
  1926.             runCatching { existing.client.forceClose("Reconnecting") }
  1927.             runCatching { existing.job?.cancel() }
  1928.         }
  1929.  
  1930.         val profile = profilePre ?: st.networks.firstOrNull { it.id == netId }
  1931.         if (profile == null) {
  1932.             val removedDesired = desiredConnected.remove(netId)
  1933.             if (removedDesired) persistDesiredNetworkIds()
  1934.             manualDisconnecting.remove(netId)
  1935.             autoReconnectJobs.remove(netId)?.cancel()
  1936.             setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Not configured") }
  1937.             if (_state.value.activeNetworkId == netId) clearConnectionNotification()
  1938.             return
  1939.         }
  1940.         val saslPasswordResult = repo.secretStore.getSaslPasswordResult(profile.id)
  1941.         val saslPassword = when (saslPasswordResult) {
  1942.             is com.boxlabs.hexdroid.data.SecretStore.SecretResult.Value -> saslPasswordResult.secret
  1943.             is com.boxlabs.hexdroid.data.SecretStore.SecretResult.KeystoreInvalidated -> {
  1944.                 // Keystore key was invalidated (biometric change, factory reset of
  1945.                 // Keystore, etc.). The stored SASL password has been cleared. Warn the user
  1946.                 // so they know why authentication will fail, rather than silently connecting
  1947.                 // without credentials and getting an opaque SASL error from the server.
  1948.                 ensureServerBuffer(netId)
  1949.                 append(
  1950.                     bufKey(netId, "*server*"),
  1951.                     from = null,
  1952.                     text = "*** ⚠ SASL credentials unavailable — the Android Keystore key was " +
  1953.                         "invalidated (this can happen after a biometric or screen-lock change). " +
  1954.                         "Please re-enter your SASL password in Network Settings.",
  1955.                     isHighlight = true,
  1956.                     doNotify = true
  1957.                 )
  1958.                 null
  1959.             }
  1960.             is com.boxlabs.hexdroid.data.SecretStore.SecretResult.NotSet -> null
  1961.         }
  1962.         val serverPassword = when (val r = repo.secretStore.getServerPasswordResult(profile.id)) {
  1963.             is com.boxlabs.hexdroid.data.SecretStore.SecretResult.Value -> r.secret
  1964.             is com.boxlabs.hexdroid.data.SecretStore.SecretResult.KeystoreInvalidated -> {
  1965.                 ensureServerBuffer(netId)
  1966.                 append(
  1967.                     bufKey(netId, "*server*"),
  1968.                     from = null,
  1969.                     text = "*** ⚠ Server password unavailable — the Android Keystore key was " +
  1970.                         "invalidated (this can happen after a biometric or screen-lock change). " +
  1971.                         "Please re-enter your server password in Network Settings.",
  1972.                     isHighlight = true,
  1973.                     doNotify = true
  1974.                 )
  1975.                 null
  1976.             }
  1977.             is com.boxlabs.hexdroid.data.SecretStore.SecretResult.NotSet -> null
  1978.         }
  1979.         val tlsCert = repo.secretStore.loadTlsClientCert(profile.id, profile.tlsClientCertId)
  1980.         val cfg = profile.toIrcConfig(
  1981.                         saslPasswordOverride = saslPassword,
  1982.                         serverPasswordOverride = serverPassword,
  1983.                         tlsClientCert = tlsCert
  1984.                     ).copy(
  1985.                         historyLimit = st.settings.ircHistoryLimit
  1986.                     )
  1987.  
  1988.         ensureServerBuffer(netId)
  1989.  
  1990.         val serverKey = bufKey(netId, "*server*")
  1991.  
  1992.         // If there's no active network with Internet capability, don't attempt to connect (it will just fail and spam).
  1993.         if (!hasInternetConnection()) {
  1994.             if (!noNetworkNotice.contains(netId)) {
  1995.                 noNetworkNotice.add(netId)
  1996.                 append(serverKey, from = null, text = "*** Please turn on WiFi or Mobile data to auto-reconnect.", doNotify = false)
  1997.             }
  1998.             setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Waiting for network…", myNick = cfg.nick) }
  1999.             if (_state.value.activeNetworkId == netId) updateConnectionNotification("Waiting for network…")
  2000.             if (_state.value.settings.autoReconnectEnabled) scheduleAutoReconnect(netId)
  2001.             return
  2002.         } else {
  2003.             noNetworkNotice.remove(netId)
  2004.         }
  2005.  
  2006.         val preservedListModes = conn?.listModes ?: NetConnState().listModes
  2007.         val newConns = st.connections + (netId to NetConnState(
  2008.             connected = false,
  2009.             connecting = true,
  2010.             status = "Connecting…",
  2011.             myNick = cfg.nick,
  2012.             listModes = preservedListModes
  2013.         ))
  2014.         _state.value = syncActiveNetworkSummary(
  2015.             st.copy(
  2016.                 connections = newConns,
  2017.                 screen = AppScreen.CHAT,
  2018.                 selectedBuffer = if (st.activeNetworkId == netId) serverKey else st.selectedBuffer
  2019.             )
  2020.         )
  2021.  
  2022.         val client = IrcClient(cfg)
  2023.         val thisClient = client
  2024.         val rt = NetRuntime(netId = netId, client = client, myNick = cfg.nick, suppressMotd = _state.value.settings.hideMotdOnConnect)
  2025.         runtimes[netId] = rt
  2026.  
  2027.         if (st.activeNetworkId == netId) updateConnectionNotification("Connecting…")
  2028.  
  2029.         rt.job?.cancel()
  2030.         rt.job = viewModelScope.launch(Dispatchers.IO) {
  2031.             // Hold a scoped WakeLock for the connect/TLS handshake burst, then release it.
  2032.             // The foreground service keeps the process alive; the lock just covers the CPU-
  2033.             // intensive initial handshake so Android can't suspend us mid-handshake.
  2034.             // Pass netId so concurrent multi-network connects each get their own lock.
  2035.             KeepAliveService.acquireScopedWakeLock(appContext, netId)
  2036.             try {
  2037.                 client.events().collect { ev ->
  2038.                     runCatching { handleEvent(netId, ev) }
  2039.                         .onFailure { t ->
  2040.                             val msg = (t.message ?: t::class.java.simpleName)
  2041.                             append(bufKey(netId, "*server*"), from = "CLIENT", text = "Event handler error: $msg", isHighlight = true)
  2042.                         }
  2043.                 }
  2044.             } finally {
  2045.                 KeepAliveService.releaseScopedWakeLock(netId)
  2046.             }
  2047.         }
  2048.                
  2049.         // If the collector exits without emitting Disconnected, clean up and maybe reconnect.
  2050.         // Guard: if the job was *cancelled* (intentional teardown — force-close, manual
  2051.         // disconnect, reconnect replacing this runtime) we must not treat it as an unexpected
  2052.         // drop. CancellationException means someone called job.cancel() on purpose.
  2053.         rt.job?.invokeOnCompletion { cause ->
  2054.             if (cause is kotlinx.coroutines.CancellationException) return@invokeOnCompletion
  2055.             viewModelScope.launch {
  2056.                 if (runtimes[netId]?.client !== thisClient) return@launch
  2057.  
  2058.                 val cur = _state.value.connections[netId]
  2059.                 val wasConnectedOrConnecting = (cur?.connected == true || cur?.connecting == true)
  2060.                 if (wasConnectedOrConnecting) {
  2061.                     append(bufKey(netId, "*server*"), from = null, text = "*** Disconnected", doNotify = false)
  2062.                     setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Disconnected") }
  2063.                     if (_state.value.activeNetworkId == netId) clearConnectionNotification()
  2064.                 }
  2065.  
  2066.                 if (!_state.value.settings.autoReconnectEnabled) return@launch
  2067.                 val wasManual = manualDisconnecting.remove(netId)
  2068.                 if (wasManual && !desiredConnected.contains(netId)) return@launch
  2069.                 if (desiredConnected.contains(netId)) scheduleAutoReconnect(netId)
  2070.             }
  2071.         }
  2072.     }
  2073.  
  2074.     fun reconnectActive() {
  2075.         val netId = _state.value.activeNetworkId ?: return
  2076.         val cur = _state.value.connections[netId]
  2077.         // If we were never connected / no runtime exists, treat reconnect as a connect.
  2078.         if ((cur?.connected != true && cur?.connecting != true) && runtimes[netId] == null) {
  2079.             connectNetwork(netId, force = true)
  2080.             return
  2081.         }
  2082.         reconnectNetwork(netId)
  2083.     }
  2084.  
  2085.     fun reconnectNetwork(netId: String) {
  2086.         val quitMsg = "Reconnecting"
  2087.         viewModelScope.launch {
  2088.             withNetLock(netId) {
  2089.             val addedDesired = desiredConnected.add(netId)
  2090.             if (addedDesired) persistDesiredNetworkIds()
  2091.             manualDisconnecting.add(netId)
  2092.             autoReconnectJobs.remove(netId)?.cancel()
  2093.             reconnectAttempts.remove(netId)
  2094.             // Clear flap detection state: the user has explicitly chosen to reconnect,
  2095.             // so we give the connection a fresh start.
  2096.             clearFlapPaused(netId)
  2097.             pingTimeoutTimestamps.remove(netId)
  2098.  
  2099.             val oldRt = runtimes.remove(netId)
  2100.             runCatching { oldRt?.client?.disconnect(quitMsg) }
  2101.             runCatching { oldRt?.client?.forceClose() }
  2102.             runCatching { oldRt?.job?.cancel() }
  2103.  
  2104.             // Mark as disconnected before re-connecting. (connectNetwork() will flip to "Connecting...".)
  2105.             setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Reconnecting…") }
  2106.  
  2107.             // Bypass the "already connecting" guard by calling the internal variant.
  2108.             connectNetworkInternal(netId, force = true)
  2109.             }
  2110.         }
  2111.     }
  2112.  
  2113.     fun disconnectActive() {
  2114.         val netId = _state.value.activeNetworkId ?: return
  2115.         disconnectNetwork(netId)
  2116.     }
  2117.  
  2118.     fun disconnectNetwork(netId: String) {
  2119.         val quitMsg = _state.value.settings.quitMessage.ifBlank { "Client disconnect" }
  2120.         viewModelScope.launch {
  2121.             withNetLock(netId) {
  2122.             val removedDesired = desiredConnected.remove(netId)
  2123.             if (removedDesired) persistDesiredNetworkIds()
  2124.             manualDisconnecting.add(netId)
  2125.             reconnectAttempts.remove(netId)  // Clear reconnect backoff
  2126.             autoReconnectJobs.remove(netId)?.cancel()
  2127.             cleanupNetworkMaps(netId)
  2128.  
  2129.             val oldRt = runtimes.remove(netId)
  2130.             runCatching { oldRt?.client?.disconnect(quitMsg) }
  2131.             // Ensure we hard-close even if QUIT can't be delivered (e.g., during network handover).
  2132.             runCatching { oldRt?.client?.forceClose() }
  2133.             runCatching { oldRt?.job?.cancel() }
  2134.  
  2135.             val st = _state.value
  2136.             val prev = st.connections[netId] ?: NetConnState()
  2137.             val newConns = st.connections + (netId to prev.copy(connected = false, connecting = false, status = "Disconnected"))
  2138.             _state.value = syncActiveNetworkSummary(st.copy(connections = newConns))
  2139.             if (st.activeNetworkId == netId) clearConnectionNotification()
  2140.         }
  2141.             }
  2142.     }
  2143.  
  2144.     /**
  2145.      * User-requested full shutdown ("Exit").
  2146.      *
  2147.      * We aggressively suppress auto-reconnect + foreground-service restarts so the user doesn't
  2148.      * have to Force Stop the app.
  2149.      */
  2150.     fun exitApp() {
  2151.         appExitRequested = true
  2152.  
  2153.         // Prevent auto-reconnect from bringing things back up while we're exiting.
  2154.         desiredConnected.clear()
  2155.         persistDesiredNetworkIds()
  2156.         manualDisconnecting.clear()
  2157.         reconnectAttempts.clear()
  2158.         autoReconnectJobs.values.forEach { it.cancel() }
  2159.         autoReconnectJobs.clear()
  2160.         noNetworkNotice.clear()
  2161.  
  2162.         // Stop the foreground service + cancel notifications immediately.
  2163.         runCatching { NotificationHelper.cancelAll(appContext) }
  2164.         runCatching { appContext.stopService(Intent(appContext, KeepAliveService::class.java)) }
  2165.         runCatching {
  2166.             val i = Intent(appContext, KeepAliveService::class.java).apply { action = KeepAliveService.ACTION_STOP }
  2167.             appContext.startService(i)
  2168.         }
  2169.         runCatching { notifier.cancelConnection() }
  2170.  
  2171.         // Then disconnect everything.
  2172.         disconnectAll()
  2173.     }
  2174.  
  2175.     fun disconnectAll() {
  2176.         val netIds = runtimes.keys.toList()
  2177.         for (id in netIds) disconnectNetwork(id)
  2178.     }
  2179.  
  2180.     // Auto-reconnect
  2181.  
  2182.     private fun scheduleAutoReconnect(netId: String) {
  2183.         val st0 = _state.value
  2184.         if (!st0.settings.autoReconnectEnabled) return
  2185.         // Per-network override.
  2186.         if (st0.networks.firstOrNull { it.id == netId }?.autoReconnect == false) return
  2187.         // One job per network.
  2188.         autoReconnectJobs.remove(netId)?.cancel()
  2189.         val serverKey = bufKey(netId, "*server*")
  2190.         autoReconnectJobs[netId] = viewModelScope.launch {
  2191.             while (isActive) {
  2192.                 val attempt = reconnectAttempts[netId] ?: 0
  2193.                 val baseDelaySec = _state.value.settings.autoReconnectDelaySec.coerceIn(
  2194.                     ConnectionConstants.RECONNECT_BASE_DELAY_MIN_SEC,
  2195.                     ConnectionConstants.RECONNECT_BASE_DELAY_MAX_SEC
  2196.                 )
  2197.                 if (attempt > 0) {
  2198.                     val exp = attempt.coerceAtMost(ConnectionConstants.RECONNECT_MAX_EXPONENT)
  2199.                     val planned = (baseDelaySec.toLong() * (1L shl exp)).coerceAtMost(ConnectionConstants.RECONNECT_MAX_DELAY_SEC)
  2200.                     val jitter = (planned * ConnectionConstants.RECONNECT_JITTER_FACTOR).toLong()
  2201.                     val actual = if (jitter > 0) planned - jitter + Random.nextLong(jitter * 2 + 1) else planned
  2202.                     // Show countdown in the server buffer, updating every 5s for long delays.
  2203.                     val tickInterval = when {
  2204.                         actual > 30 -> 5L
  2205.                         actual > 10 -> 2L
  2206.                         else -> 1L
  2207.                     }
  2208.                     var remaining = actual
  2209.                     setNetConn(netId) { it.copy(status = "Reconnecting in ${remaining}s…") }
  2210.                     append(serverKey, from = null,
  2211.                         text = "*** Reconnecting in ${remaining}s (attempt ${attempt + 1})…",
  2212.                         doNotify = false)
  2213.                     while (remaining > 0 && isActive) {
  2214.                         val tick = remaining.coerceAtMost(tickInterval)
  2215.                         delay(tick * 1000L)
  2216.                         remaining -= tick
  2217.                         if (remaining > 0) {
  2218.                             setNetConn(netId) { it.copy(status = "Reconnecting in ${remaining}s…") }
  2219.                         }
  2220.                     }
  2221.                     if (!isActive) break
  2222.                 }
  2223.  
  2224.                 // Stop if the user no longer wants this network connected.
  2225.                 if (!desiredConnected.contains(netId)) break
  2226.                 // If the user explicitly disconnected/reconnected, don't fight them.
  2227.                 if (manualDisconnecting.contains(netId)) continue
  2228.  
  2229.                 val st = _state.value
  2230.                 // If the profile no longer exists, stop retrying.
  2231.                 if (st.networks.none { it.id == netId }) {
  2232.                     desiredConnected.remove(netId)
  2233.                     reconnectAttempts.remove(netId)
  2234.                     break
  2235.                 }
  2236.  
  2237.                 val cur = st.connections[netId]
  2238.                 if (cur?.connected == true) {
  2239.                     reconnectAttempts.remove(netId)
  2240.                     break
  2241.                 }
  2242.                 if (cur?.connecting == true) continue
  2243.  
  2244.  
  2245.                 // If there's no connectivity at all (Wi‑Fi + Mobile disabled), pause auto-reconnect until it returns.
  2246.                 if (!hasInternetConnection()) {
  2247.                     if (!noNetworkNotice.contains(netId)) {
  2248.                         noNetworkNotice.add(netId)
  2249.                         append(serverKey, from = null, text = "*** Please turn on WiFi or Mobile data to auto-reconnect.", doNotify = false)
  2250.                         setNetConn(netId) { it.copy(connected = false, connecting = false, status = "Waiting for network…") }
  2251.                         if (_state.value.activeNetworkId == netId) updateConnectionNotification("Waiting for network…")
  2252.                     }
  2253.                     delay(5000L)
  2254.                     continue
  2255.                 } else if (noNetworkNotice.remove(netId)) {
  2256.                     // Connectivity is back; let the user know once and try again.
  2257.                     append(serverKey, from = null, text = "*** Network available. Retrying…", doNotify = false)
  2258.                 }
  2259.  
  2260.                 // Pause reconnect when battery saver is active to avoid draining battery.
  2261.                 // We still attempt to reconnect when the user has the app in the foreground,
  2262.                 // but when backgrounded + battery saver on, we wait until saver turns off.
  2263.                 if (!AppVisibility.isForeground) {
  2264.                     val pm = appContext.getSystemService(android.content.Context.POWER_SERVICE)
  2265.                         as? android.os.PowerManager
  2266.                     if (pm?.isPowerSaveMode == true) {
  2267.                         append(serverKey, from = null, text = "*** Battery saver is active - reconnect paused. Will retry when battery saver is off.", doNotify = false)
  2268.                         setNetConn(netId) { it.copy(status = "Paused (battery saver)") }
  2269.                         // Poll every 30s until battery saver is disabled or app comes to foreground.
  2270.                         while (!AppVisibility.isForeground && pm.isPowerSaveMode && isActive) {
  2271.                             delay(30_000L)
  2272.                         }
  2273.                         if (isActive && !pm.isPowerSaveMode) {
  2274.                             append(serverKey, from = null, text = "*** Battery saver off. Retrying…", doNotify = false)
  2275.                         }
  2276.                         continue
  2277.                     }
  2278.                 }
  2279.  
  2280.                 if (attempt > 0) {
  2281.                     append(serverKey, from = null, text = "*** Retrying to connect (attempt ${attempt + 1})…", doNotify = false)
  2282.                 }
  2283.                 setNetConn(netId) { it.copy(status = "Retrying to connect…") }
  2284.                 if (st.activeNetworkId == netId) updateConnectionNotification("Retrying to connect…")
  2285.  
  2286.                 // Force a clean reconnect (drops stale runtimes if present).
  2287.                 withNetLock(netId) { KeepAliveService.withWakeLock(appContext) { connectNetworkInternal(netId, force = true) } }
  2288.                 reconnectAttempts[netId] = (attempt + 1).coerceAtMost(ConnectionConstants.RECONNECT_MAX_ATTEMPTS)
  2289.             }
  2290.             autoReconnectJobs.remove(netId)
  2291.         }
  2292.     }
  2293.  
  2294.     /**
  2295.      * when the app returns to the foreground, re-check the socket state so the UI doesn't drift.
  2296.      * (E.g. if a lifecycle event caused UI state to reset while the socket is still alive.)
  2297.      * Also triggers reconnection for networks that should be connected but aren't.
  2298.      */
  2299.     /**
  2300.      * When the app returns to the foreground, re-check the socket state so the UI doesn't drift.
  2301.      * (E.g. if a lifecycle event caused UI state to reset while the socket is still alive.)
  2302.      * Also triggers reconnection for networks that should be connected but went down while backgrounded.
  2303.      *
  2304.      * Important: we skip any network that is actively connecting or already has a reconnect job
  2305.      * queued — isConnectedNow() can transiently return false during the handshake window, and
  2306.      * interfering with an in-flight attempt would cause a duplicate reconnect race.
  2307.      */
  2308.     fun resyncConnectionsOnResume() {
  2309.         val st = _state.value
  2310.         var changed = false
  2311.         val newMap = st.connections.toMutableMap()
  2312.         val networksToReconnect = mutableListOf<String>()
  2313.  
  2314.         for (net in st.networks) {
  2315.             val rt = runtimes[net.id]
  2316.             val cur = newMap[net.id] ?: NetConnState()
  2317.  
  2318.             // Don't touch anything that is already mid-connect or has a reconnect scheduled —
  2319.             // isConnectedNow() is unreliable during the handshake and we'd create a double-reconnect.
  2320.             if (cur.connecting) continue
  2321.             if (autoReconnectJobs.containsKey(net.id)) continue
  2322.  
  2323.             val actual = rt?.client?.isConnectedNow() == true
  2324.  
  2325.             // Socket is alive but UI thinks we're disconnected — correct the UI.
  2326.             if (actual && !cur.connected) {
  2327.                 newMap[net.id] = cur.copy(connected = true, connecting = false, status = "Connected")
  2328.                 changed = true
  2329.             }
  2330.  
  2331.             // Socket is gone but UI thinks we're connected — correct the UI and maybe reconnect.
  2332.             if (!actual && cur.connected) {
  2333.                 newMap[net.id] = cur.copy(connected = false, connecting = false, status = "Disconnected")
  2334.                 changed = true
  2335.                 if (desiredConnected.contains(net.id)) networksToReconnect.add(net.id)
  2336.             }
  2337.  
  2338.             // Not connected, not connecting, but should be — reconnect.
  2339.             if (!actual && !cur.connected && desiredConnected.contains(net.id)) {
  2340.                 if (!networksToReconnect.contains(net.id)) networksToReconnect.add(net.id)
  2341.             }
  2342.         }
  2343.  
  2344.         if (changed) {
  2345.             _state.value = syncActiveNetworkSummary(st.copy(connections = newMap))
  2346.         }
  2347.  
  2348.         if (networksToReconnect.isNotEmpty() && hasInternetConnection()) {
  2349.             viewModelScope.launch {
  2350.                 delay(500) // Brief delay to let UI settle
  2351.                 for (netId in networksToReconnect) {
  2352.                     // Re-check: a reconnect job may have been scheduled in the meantime.
  2353.                     if (autoReconnectJobs.containsKey(netId)) continue
  2354.                     val cur = _state.value.connections[netId]
  2355.                     if (cur?.connecting == true || cur?.connected == true) continue
  2356.                     append(bufKey(netId, "*server*"), from = null, text = "*** Resuming connection…", doNotify = false)
  2357.                     connectNetwork(netId, force = true)
  2358.                 }
  2359.             }
  2360.         }
  2361.     }
  2362.  
  2363.     // Sending
  2364.  
  2365.  
  2366.     /**
  2367.      * Record that the user has read up to the current last message in [bufferKey], updating
  2368.      * [UiBuffer.lastReadTimestamp] and sending MARKREAD to the server if the cap is available.
  2369.      * Passing an explicit [timestamp] overrides the last-message lookup (used for server-driven
  2370.      * read marker updates, e.g. from a bouncer).
  2371.      */
  2372.     fun markBufferRead(bufferKey: String, timestamp: String? = null) {
  2373.         if (timestamp != null) {
  2374.             // Explicit timestamp from server - apply directly.
  2375.             val (netId, bufferName) = splitKey(bufferKey)
  2376.             val rt = runtimes[netId] ?: return
  2377.             val st = _state.value
  2378.             val buf = st.buffers[bufferKey]
  2379.             if (buf != null) {
  2380.                 _state.value = st.copy(buffers = st.buffers + (bufferKey to buf.copy(lastReadTimestamp = timestamp)))
  2381.             }
  2382.             if (rt.client.hasCap("draft/read-marker")) {
  2383.                 viewModelScope.launch { rt.client.sendRaw("MARKREAD $bufferName timestamp=$timestamp") }
  2384.             }
  2385.         } else {
  2386.             // No explicit timestamp: anchor to the last message's own time.
  2387.             stampReadMarker(bufferKey)
  2388.         }
  2389.     }
  2390.  
  2391.     // --- Outgoing draft/typing indicator ---
  2392.     // typingLastKey stores the FULL buffer key (netId::bufferName) so that cross-network
  2393.     // "done" is sent to the correct connection when the user switches between buffers on
  2394.     // different networks (e.g. #general on net-A and #general on net-B).
  2395.     private var typingDoneJob: kotlinx.coroutines.Job? = null
  2396.     private var typingLastKey: String? = null
  2397.  
  2398.     // Track when we last sent "active" to enforce the IRCv3 minimum
  2399.     // interval of 3 seconds between "active" sends. Without this, every keystroke fires a
  2400.     // TAGMSG, which causes Excess Flood disconnection on any server with normal flood limits.
  2401.     private var typingActiveLastSentMs: Long = 0L
  2402.     private val TYPING_ACTIVE_INTERVAL_MS = 3_000L   // IRCv3 spec minimum
  2403.  
  2404.     // Auto-expiry jobs for *received* typing indicators.
  2405.     // Key: "$bufferKey/$nick". IRCv3 spec recommends expiring after 30 s with no update.
  2406.     private val receivedTypingExpiryJobs = mutableMapOf<String, kotlinx.coroutines.Job>()
  2407.  
  2408.     /**
  2409.      * Called by the UI whenever the input text changes. Sends "active" typing status at most
  2410.      * once every 3 seconds per the IRCv3 spec, then schedules a "paused" → "done" timeout
  2411.      * if the user stops typing. Sending an empty string immediately sends "done".
  2412.      *
  2413.      * No-op if the user has disabled [UiSettings.sendTypingIndicator] in Settings (privacy).
  2414.      */
  2415.     fun notifyTypingChanged(text: String) {
  2416.         val st = _state.value
  2417.  
  2418.         // Privacy gate: user must explicitly opt in to broadcasting typing status.
  2419.         if (!st.settings.sendTypingIndicator) return
  2420.  
  2421.         val currentKey = st.selectedBuffer
  2422.         if (currentKey.isBlank()) return
  2423.         val (netId, bufferName) = splitKey(currentKey)
  2424.         val rt = runtimes[netId] ?: return
  2425.         if (!rt.client.hasCap("draft/typing") && !rt.client.hasCap("typing")
  2426.             && !rt.client.hasCap("message-tags")) return
  2427.         if (bufferName == "*server*") return
  2428.  
  2429.         typingDoneJob?.cancel()
  2430.  
  2431.         if (text.isEmpty()) {
  2432.             // User cleared input — send "done" immediately to the correct network.
  2433.             // look up the client at send time rather than capturing
  2434.             // `rt` here. If the user reconnected between keystrokes, `rt` would be the old
  2435.             // disconnected client; runtimes[prevNet] gives the live one.
  2436.             typingLastKey?.let { prevKey ->
  2437.                 val (prevNet, prevBuf) = splitKey(prevKey)
  2438.                 viewModelScope.launch { runtimes[prevNet]?.client?.sendTypingStatus(prevBuf, "done") }
  2439.             }
  2440.             typingLastKey = null
  2441.             typingActiveLastSentMs = 0L
  2442.             return
  2443.         }
  2444.  
  2445.         // If buffer changed, send "done" to the OLD buffer on whichever network it belonged to.
  2446.         val prevKey = typingLastKey
  2447.         if (prevKey != null && prevKey != currentKey) {
  2448.             val (prevNet, prevBuf) = splitKey(prevKey)
  2449.             viewModelScope.launch { runtimes[prevNet]?.client?.sendTypingStatus(prevBuf, "done") }
  2450.             typingActiveLastSentMs = 0L
  2451.         }
  2452.  
  2453.         typingLastKey = currentKey
  2454.  
  2455.         // only send "active" if 3+ seconds have passed since the
  2456.         // last send. The IRCv3 draft/typing spec explicitly requires this rate limit.
  2457.         // Capture only the string ids, not the client reference, to avoid the stale-client bug.
  2458.         val now = System.currentTimeMillis()
  2459.         if (now - typingActiveLastSentMs >= TYPING_ACTIVE_INTERVAL_MS) {
  2460.             typingActiveLastSentMs = now
  2461.             val capturedNetId = netId
  2462.             val capturedBuffer = bufferName
  2463.             viewModelScope.launch {
  2464.                 runtimes[capturedNetId]?.client?.sendTypingStatus(capturedBuffer, "active")
  2465.             }
  2466.         }
  2467.  
  2468.         // After 6 s of inactivity -> "paused"; after another 24 s -> "done".
  2469.         val capturedNetId = netId
  2470.         val capturedBuffer = bufferName
  2471.         typingDoneJob = viewModelScope.launch {
  2472.             delay(6_000L)
  2473.             runtimes[capturedNetId]?.client?.sendTypingStatus(capturedBuffer, "paused")
  2474.             delay(24_000L)
  2475.             runtimes[capturedNetId]?.client?.sendTypingStatus(capturedBuffer, "done")
  2476.             typingLastKey = null
  2477.         }
  2478.     }
  2479.  
  2480.     fun sendInput(raw: String) {
  2481.         val st = _state.value
  2482.         val currentKey = st.selectedBuffer
  2483.         if (currentKey.isBlank()) return
  2484.         val (netId, bufferName) = splitKey(currentKey)
  2485.         // Some commands (/SYSINFO) should work even when disconnected.
  2486.         // For network-bound commands, we'll surface a friendly "Not connected" message.
  2487.         val rt = runtimes[netId]
  2488.         val c = rt?.client
  2489.  
  2490.         viewModelScope.launch {
  2491.             val trimmed = raw.trim()
  2492.             if (trimmed.isEmpty()) return@launch
  2493.  
  2494.             // Strip IRC formatting codes (bold, colour, italic, etc.) from the front of the
  2495.             // input before checking for a leading '/'. If the user has bold or colour active
  2496.             // in the input field, the raw string starts with formatting bytes, not '/'.
  2497.             val strippedForCommandCheck = trimmed.trimStart(
  2498.                 '\u0002', '\u0003', '\u000f', '\u0016', '\u001d', '\u001e', '\u001f'
  2499.             ).let {
  2500.                 // \u0003 may be followed by colour digits — skip them too
  2501.                 it.replace(Regex("^\u0003\\d{0,2}(?:,\\d{0,2})?"), "")
  2502.             }
  2503.  
  2504.             // Check if this is a command (starts with /)
  2505.             // Use the formatting-stripped version for detection, but keep `trimmed` for
  2506.             // the actual command content so explicit /me with colour still works.
  2507.             if (strippedForCommandCheck.startsWith("/")) {
  2508.                 // Use the stripped string to parse the command name/args, but content
  2509.                 // after the command verb is taken from strippedForCommandCheck directly.
  2510.                 val cmdLine = strippedForCommandCheck.drop(1).substringBefore('\n').trim()
  2511.                 val cmd = cmdLine.substringBefore(' ').lowercase()
  2512.  
  2513.                 when (cmd) {
  2514.                     "list" -> {
  2515.                         goTo(AppScreen.LIST)
  2516.                         return@launch
  2517.                     }
  2518.                     "sysinfo" -> {
  2519.                         val line = withContext(Dispatchers.Default) { buildSysInfoLine() }
  2520.                         val fromNick = st.connections[netId]?.myNick ?: st.myNick
  2521.                         // If we're in a channel/query and connected, send it as a normal message.
  2522.                         if (bufferName != "*server*" && c != null) {
  2523.                             c.privmsg(bufferName, line)
  2524.                             append(currentKey, from = fromNick, text = line, isLocal = true)
  2525.                             recordLocalSend(netId, currentKey, line, isAction = false)
  2526.                         } else {
  2527.                             append(currentKey, from = fromNick, text = line, isLocal = true)
  2528.                         }
  2529.                         return@launch
  2530.                     }
  2531.  
  2532.                     "find", "grep", "search" -> {
  2533.                         val query = cmdLine.substringAfter(' ', "").trim()
  2534.                         if (query.isBlank()) {
  2535.                             append(currentKey, from = null, text = "*** Usage: /find <text>", isLocal = true, doNotify = false)
  2536.                             return@launch
  2537.                         }
  2538.                         val msgs = _state.value.buffers[currentKey]?.messages.orEmpty()
  2539.                         val matches = msgs.filter {
  2540.                             it.text.contains(query, ignoreCase = true) ||
  2541.                                 it.from?.contains(query, ignoreCase = true) == true
  2542.                         }
  2543.                         if (matches.isEmpty()) {
  2544.                             append(currentKey, from = null, text = "*** No matches for \"$query\"", isLocal = true, doNotify = false)
  2545.                             return@launch
  2546.                         }
  2547.                         _state.value = _state.value.copy(
  2548.                             findOverlay = FindOverlay(
  2549.                                 query = query,
  2550.                                 matchIds = matches.map { it.id },
  2551.                                 currentIndex = matches.lastIndex,
  2552.                                 bufferKey = currentKey,
  2553.                             )
  2554.                         )
  2555.                         return@launch
  2556.                     }
  2557.  
  2558.                     "gsearch", "gfind" -> {
  2559.                         // Global search across all loaded buffers on the current network.
  2560.                         val query = cmdLine.substringAfter(' ', "").trim()
  2561.                         if (query.isBlank()) {
  2562.                             append(currentKey, from = null, text = "*** Usage: /gsearch <text>", isLocal = true, doNotify = false)
  2563.                             return@launch
  2564.                         }
  2565.                         val allMatches = _state.value.buffers
  2566.                             .filter { (k, _) -> splitKey(k).first == netId }
  2567.                             .flatMap { (_, buf) ->
  2568.                                 buf.messages.filter {
  2569.                                     it.text.contains(query, ignoreCase = true) ||
  2570.                                         it.from?.contains(query, ignoreCase = true) == true
  2571.                                 }
  2572.                             }
  2573.                             .sortedBy { it.timeMs }
  2574.                         if (allMatches.isEmpty()) {
  2575.                             append(currentKey, from = null, text = "*** No matches for \"$query\" across ${_state.value.buffers.count { splitKey(it.key).first == netId }} buffers", isLocal = true, doNotify = false)
  2576.                             return@launch
  2577.                         }
  2578.                         _state.value = _state.value.copy(
  2579.                             findOverlay = FindOverlay(
  2580.                                 query = query,
  2581.                                 matchIds = allMatches.map { it.id },
  2582.                                 currentIndex = allMatches.lastIndex,
  2583.                                 bufferKey = "GLOBAL:$netId",
  2584.                             )
  2585.                         )
  2586.                         append(currentKey, from = null, text = "*** Found ${allMatches.size} matches for \"$query\" - use /gsearch overlay to navigate", isLocal = true, doNotify = false)
  2587.                         return@launch
  2588.                     }
  2589.  
  2590.                     "flip" -> {
  2591.                         // secret table-flip easter egg
  2592.                         if (bufferName == "*server*") return@launch
  2593.                         val rt = runtimes[netId] ?: return@launch
  2594.                         val myNick = _state.value.connections[netId]?.myNick ?: _state.value.myNick
  2595.                         rt.client.sendRaw("PRIVMSG $bufferName :(╯°□°)╯┬─┬")
  2596.                         append(currentKey, from = myNick, text = "(╯°□°)╯┬─┬", isLocal = true, doNotify = false)
  2597.                         kotlinx.coroutines.delay(800L)
  2598.                         rt.client.sendRaw("PRIVMSG $bufferName :(ノ°□°)ノ┻━┻")
  2599.                         append(currentKey, from = myNick, text = "(ノ°□°)ノ┻━┻", isLocal = true, doNotify = false)
  2600.                         return@launch
  2601.                     }
  2602.  
  2603.                     "ignore" -> {
  2604.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2605.                         val listOnly = arg.isBlank() || arg.equals("list", ignoreCase = true) || arg.equals("ls", ignoreCase = true)
  2606.                         if (listOnly) {
  2607.                             val net = _state.value.networks.firstOrNull { it.id == netId }
  2608.                             val items = net?.ignoredNicks.orEmpty()
  2609.                             if (items.isEmpty()) {
  2610.                                 append(currentKey, from = null, text = "*** Ignore list is empty. Usage: /ignore <nick>", isLocal = true, doNotify = false)
  2611.                             } else {
  2612.                                 append(currentKey, from = null, text = "*** Ignored (${items.size}): ${items.joinToString(", ")}", isLocal = true, doNotify = false)
  2613.                             }
  2614.                             return@launch
  2615.                         }
  2616.                         val nick = arg.substringBefore(' ').trim()
  2617.                         val canon = canonicalIgnoreNick(nick)
  2618.                         if (canon == null) {
  2619.                             append(currentKey, from = null, text = "*** Usage: /ignore <nick>", isLocal = true, doNotify = false)
  2620.                             return@launch
  2621.                         }
  2622.                         ignoreNick(netId, canon)
  2623.                         return@launch
  2624.                     }
  2625.  
  2626.                     "unignore" -> {
  2627.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2628.                         if (arg.isBlank()) {
  2629.                             append(currentKey, from = null, text = "*** Usage: /unignore <nick>", isLocal = true, doNotify = false)
  2630.                             return@launch
  2631.                         }
  2632.                         val nick = arg.substringBefore(' ').trim()
  2633.                         val canon = canonicalIgnoreNick(nick)
  2634.                         if (canon == null) {
  2635.                             append(currentKey, from = null, text = "*** Usage: /unignore <nick>", isLocal = true, doNotify = false)
  2636.                             return@launch
  2637.                         }
  2638.                         unignoreNick(netId, canon)
  2639.                         return@launch
  2640.                     }
  2641.  
  2642.                     "motd" -> {
  2643.                         if (c == null) {
  2644.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2645.                             return@launch
  2646.                         }
  2647.                         // If user explicitly requests MOTD, show it even if we hide on connect
  2648.                         runtimes[netId]?.apply { manualMotdAtMs = System.currentTimeMillis(); suppressMotd = false }
  2649.                         val args = cmdLine.substringAfter(' ', "").trim()
  2650.                         val line = if (args.isBlank()) "MOTD" else "MOTD $args"
  2651.                         c.sendRaw(line)
  2652.                         return@launch
  2653.                     }
  2654.                     "names" -> {
  2655.                         if (c == null) {
  2656.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2657.                             return@launch
  2658.                         }
  2659.  
  2660.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2661.                         val target = when {
  2662.                             arg.isNotBlank() -> arg.substringBefore(' ')
  2663.                             bufferName != "*server*" -> bufferName
  2664.                             else -> ""
  2665.                         }
  2666.  
  2667.                         if (target.isBlank()) {
  2668.                             append(currentKey, from = null, text = "*** Usage: /names #channel", doNotify = false)
  2669.                             return@launch
  2670.                         }
  2671.  
  2672.                         // Track this request so we can print a clean consolidated list.
  2673.                         rt.namesRequests[namesKeyFold(target)] = NamesRequest(replyBufferKey = currentKey)
  2674.                         c.sendRaw("NAMES $target")
  2675.                         return@launch
  2676.                     }
  2677.  
  2678.                     "me" -> {
  2679.                         if (c == null) {
  2680.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2681.                             return@launch
  2682.                         }
  2683.                         val msg = cmdLine.drop(2).trim()
  2684.                         if (msg.isBlank()) return@launch
  2685.  
  2686.                         // DCC CHAT buffer: send over the DCC socket instead of IRC.
  2687.                         if (isDccChatBufferName(bufferName)) {
  2688.                             sendDccChatLine(currentKey, msg, isAction = true)
  2689.                             return@launch
  2690.                         }
  2691.  
  2692.                         val target = if (bufferName == "*server*") return@launch else bufferName
  2693.                         c.sendRaw("PRIVMSG $target :\u0001ACTION $msg\u0001")
  2694.                         append(currentKey, from = st.connections[netId]?.myNick ?: st.myNick, text = msg, isAction = true, isLocal = true)
  2695.                         recordLocalSend(netId, currentKey, msg, isAction = true)
  2696.                         return@launch
  2697.                     }
  2698.  
  2699.                     "banlist" -> {
  2700.                         if (c == null) {
  2701.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2702.                             return@launch
  2703.                         }
  2704.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2705.                         val chan = when {
  2706.                             arg.isNotBlank() -> arg.substringBefore(' ')
  2707.                             isChannelOnNet(netId, bufferName) -> bufferName
  2708.                             else -> ""
  2709.                         }
  2710.                         if (chan.isBlank() || !isChannelOnNet(netId, chan)) {
  2711.                             append(currentKey, from = null, text = "*** Usage: /banlist #channel", doNotify = false)
  2712.                             return@launch
  2713.                         }
  2714.                         startBanList(netId, chan)
  2715.                         c.sendRaw("MODE $chan +b")
  2716.                         return@launch
  2717.                     }
  2718.  
  2719.                     "quietlist" -> {
  2720.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2721.                         val chan = when {
  2722.                             arg.isNotBlank() -> arg.substringBefore(' ')
  2723.                             isChannelOnNet(netId, bufferName) -> bufferName
  2724.                             else -> ""
  2725.                         }
  2726.                         if (chan.isBlank() || !isChannelOnNet(netId, chan)) {
  2727.                             append(currentKey, from = null, text = "*** Usage: /quietlist #channel", doNotify = false)
  2728.                             return@launch
  2729.                         }
  2730.                         if (c == null) {
  2731.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2732.                             return@launch
  2733.                         }
  2734.                         startQuietList(netId, chan)
  2735.                         c.sendRaw("MODE $chan +q")
  2736.                         return@launch
  2737.                     }
  2738.  
  2739.                     "exceptlist" -> {
  2740.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2741.                         val chan = when {
  2742.                             arg.isNotBlank() -> arg.substringBefore(' ')
  2743.                             isChannelOnNet(netId, bufferName) -> bufferName
  2744.                             else -> ""
  2745.                         }
  2746.                         if (chan.isBlank() || !isChannelOnNet(netId, chan)) {
  2747.                             append(currentKey, from = null, text = "*** Usage: /exceptlist #channel", doNotify = false)
  2748.                             return@launch
  2749.                         }
  2750.                         if (c == null) {
  2751.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2752.                             return@launch
  2753.                         }
  2754.                         startExceptList(netId, chan)
  2755.                         c.sendRaw("MODE $chan +e")
  2756.                         return@launch
  2757.                     }
  2758.  
  2759.                     "invexlist" -> {
  2760.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2761.                         val chan = when {
  2762.                             arg.isNotBlank() -> arg.substringBefore(' ')
  2763.                             isChannelOnNet(netId, bufferName) -> bufferName
  2764.                             else -> ""
  2765.                         }
  2766.                         if (chan.isBlank() || !isChannelOnNet(netId, chan)) {
  2767.                             append(currentKey, from = null, text = "*** Usage: /invexlist #channel", doNotify = false)
  2768.                             return@launch
  2769.                         }
  2770.                         if (c == null) {
  2771.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2772.                             return@launch
  2773.                         }
  2774.                         startInvexList(netId, chan)
  2775.                         c.sendRaw("MODE $chan +I")
  2776.                         return@launch
  2777.                     }
  2778.  
  2779.                     "close" -> {
  2780.                         // Close the current buffer, or a specific buffer name on this network.
  2781.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2782.                         val targetName = when {
  2783.                             arg.isNotBlank() -> arg.substringBefore(' ')
  2784.                             else -> bufferName
  2785.                         }
  2786.                         if (targetName.isBlank() || targetName == "*server*") return@launch
  2787.                         val key = if (arg.isBlank()) currentKey else resolveBufferKey(netId, targetName)
  2788.                         if (isChannelOnNet(netId, targetName)) {
  2789.                             val cli = runtimes[netId]?.client
  2790.                             if (cli != null) {
  2791.                                 pendingCloseAfterPart.add(key)
  2792.                                 // Close immediately; we still send PART but suppress recreating the buffer on the echo.
  2793.                                 removeBuffer(key)
  2794.                                 cli.sendRaw("PART $targetName")
  2795.                             } else {
  2796.                                 removeBuffer(key)
  2797.                             }
  2798.                         } else {
  2799.                             removeBuffer(key)
  2800.                         }
  2801.                         return@launch
  2802.                     }
  2803.  
  2804.                     "closekey" -> {
  2805.                         // Internal helper used by the sidebar X button: /closekey <netId>::<buffer>
  2806.                         val arg = cmdLine.substringAfter(' ', "").trim()
  2807.                         if (arg.isBlank() || !arg.contains("::")) return@launch
  2808.                         val (targetNet, targetName) = splitKey(arg)
  2809.                         if (targetNet.isBlank() || targetName.isBlank() || targetName == "*server*") return@launch
  2810.                         // IMPORTANT: keep the *exact* buffer key the user clicked.
  2811.                         // (If we resolve/normalize here, we can end up closing a different buffer when
  2812.                         // duplicate buffers exist due to case differences, leaving the clicked one stuck.)
  2813.                         val key = arg
  2814.                         if (isChannelOnNet(targetNet, targetName)) {
  2815.                             val cli = runtimes[targetNet]?.client
  2816.                             if (cli != null) {
  2817.                                 pendingCloseAfterPart.add(key)
  2818.                                 // Close immediately; we still send PART but suppress recreating the buffer on the echo.
  2819.                                 removeBuffer(key)
  2820.                                 cli.sendRaw("PART $targetName")
  2821.                             } else {
  2822.                                 removeBuffer(key)
  2823.                             }
  2824.                         } else {
  2825.                             removeBuffer(key)
  2826.                         }
  2827.                         return@launch
  2828.                     }
  2829.  
  2830.                     "dcc" -> {
  2831.                         val rest = cmdLine.substringAfter(' ', "").trim()
  2832.                         val sub = rest.substringBefore(' ').lowercase(Locale.ROOT)
  2833.                         val arg = rest.substringAfter(' ', "").trim()
  2834.                         when (sub) {
  2835.                             "chat" -> {
  2836.                                 if (arg.isBlank()) {
  2837.                                     append(currentKey, from = null, text = "*** Usage: /dcc chat <nick>", doNotify = false)
  2838.                                 } else {
  2839.                                     startDccChat(arg.substringBefore(' '))
  2840.                                 }
  2841.                             }
  2842.                             else -> append(currentKey, from = null, text = "*** Usage: /dcc chat <nick>", doNotify = false)
  2843.                         }
  2844.                         return@launch
  2845.                     }
  2846.  
  2847.                     "mode" -> {
  2848.                         if (c == null) {
  2849.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2850.                             return@launch
  2851.                         }
  2852.  
  2853.                         // Intercept MODE list requests (e.g. +b, +q, +e, +I) so we can populate the list UI.
  2854.                         val args = cmdLine.substringAfter(' ', "").trim()
  2855.                         val toks = args.split(Regex("\\s+")).filter { it.isNotBlank() }
  2856.                         if (toks.isNotEmpty()) {
  2857.                             val t0 = toks.getOrNull(0).orEmpty()
  2858.                             val t1 = toks.getOrNull(1)
  2859.                             val t2 = toks.getOrNull(2)
  2860.  
  2861.                             val (chan, modeTok, maskTok) = if (isChannelOnNet(netId, t0)) {
  2862.                                 Triple(t0, t1, t2)
  2863.                             } else if (isChannelOnNet(netId, bufferName)) {
  2864.                                 Triple(bufferName, t0, t1)
  2865.                             } else {
  2866.                                 Triple("", null, null)
  2867.                             }
  2868.  
  2869.                             val modeNorm = modeTok?.trim()
  2870.                             val isListQuery = (maskTok == null)
  2871.                             if (chan.isNotBlank() && isListQuery) {
  2872.                                 when (modeNorm) {
  2873.                                     "+b", "b" -> {
  2874.                                         startBanList(netId, chan)
  2875.                                         c.sendRaw("MODE $chan +b")
  2876.                                         return@launch
  2877.                                     }
  2878.                                     "+q", "q" -> {
  2879.                                         startQuietList(netId, chan)
  2880.                                         c.sendRaw("MODE $chan +q")
  2881.                                         return@launch
  2882.                                     }
  2883.                                     "+e", "e" -> {
  2884.                                         startExceptList(netId, chan)
  2885.                                         c.sendRaw("MODE $chan +e")
  2886.                                         return@launch
  2887.                                     }
  2888.                                     "+I", "I" -> {
  2889.                                         startInvexList(netId, chan)
  2890.                                         c.sendRaw("MODE $chan +I")
  2891.                                         return@launch
  2892.                                     }
  2893.                                 }
  2894.                             }
  2895.                         }
  2896.  
  2897.                         // Default MODE handling
  2898.                         c.handleSlashCommand(cmdLine, bufferName)
  2899.                         return@launch
  2900.                     }
  2901.  
  2902.                     else -> {
  2903.                         if (c == null) {
  2904.                             append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2905.                             return@launch
  2906.                         }
  2907.                         // Let the IRC client handle it
  2908.                         c.handleSlashCommand(cmdLine, bufferName)
  2909.                         return@launch
  2910.                     }
  2911.                 }
  2912.             }
  2913.  
  2914.             // Regular text message - join any newlines into a single message.
  2915.             // Only split if the message exceeds the server's max line length.
  2916.             // The IRC protocol limit is typically 512 bytes (including CRLF), but many
  2917.             // servers support more via ISUPPORT LINELEN.
  2918.            
  2919.             // Replace newlines with spaces to create one continuous message
  2920.             val fullMessage = trimmed.replace('\n', ' ').replace('\r', ' ').replace("  ", " ").trim()
  2921.            
  2922.             if (fullMessage.isEmpty()) return@launch
  2923.            
  2924.             if (isDccChatBufferName(bufferName)) {
  2925.                 sendDccChatLine(currentKey, fullMessage, isAction = false)
  2926.                 return@launch
  2927.             }
  2928.             if (c == null) {
  2929.                 append(currentKey, from = null, text = "*** Not connected.", doNotify = false)
  2930.                 return@launch
  2931.             }
  2932.             if (bufferName == "*server*") {
  2933.                 c.sendRaw(fullMessage)
  2934.                 return@launch
  2935.             }
  2936.            
  2937.             // Calculate max message length for PRIVMSG
  2938.             // Format: ":nick!user@host PRIVMSG <target> :<message>\r\n"
  2939.             // We derive the limit from the server's LINELEN ISUPPORT token when available.
  2940.             // LINELEN covers the full wire line including CRLF; subtract the overhead of
  2941.             // the longest plausible sender prefix + "PRIVMSG <target> :" to get the safe
  2942.             // payload budget. We cap the overhead estimate conservatively at 100 bytes
  2943.             // (64 max nick + ident + host + "!@" + " PRIVMSG " + channel + " :" + "\r\n").
  2944.             val myNick = st.connections[netId]?.myNick ?: st.myNick
  2945.             val serverLimit = runtimes[netId]?.support?.linelen ?: 512
  2946.             val maxMsgLen = (serverLimit - 100).coerceIn(200, serverLimit - 10)
  2947.            
  2948.             // Cancel pending typing-done timer and send "done" immediately on send.
  2949.             typingDoneJob?.cancel()
  2950.             typingDoneJob = null
  2951.             if (st.settings.sendTypingIndicator && typingLastKey == currentKey) {
  2952.                 c.sendTypingStatus(bufferName, "done")
  2953.                 typingLastKey = null
  2954.             }
  2955.  
  2956.             // Split message if it exceeds max length
  2957.             val chunks = splitMessageByLength(fullMessage, maxMsgLen)
  2958.            
  2959.             for (chunk in chunks) {
  2960.                 if (chunk.isEmpty()) continue
  2961.                 c.privmsg(bufferName, chunk)
  2962.                 append(currentKey, from = myNick, text = chunk, isLocal = true)
  2963.                 recordLocalSend(netId, currentKey, chunk, isAction = false)
  2964.             }
  2965.         }
  2966.     }
  2967.    
  2968.     /**
  2969.      * Split a message into chunks that don't exceed maxLen bytes (UTF-8).
  2970.      * Tries to split on word boundaries when possible.
  2971.      */
  2972.     private fun splitMessageByLength(text: String, maxLen: Int): List<String> {
  2973.         if (text.toByteArray(Charsets.UTF_8).size <= maxLen) {
  2974.             return listOf(text)
  2975.         }
  2976.        
  2977.         val chunks = mutableListOf<String>()
  2978.         var remaining = text
  2979.        
  2980.         while (remaining.isNotEmpty()) {
  2981.             if (remaining.toByteArray(Charsets.UTF_8).size <= maxLen) {
  2982.                 chunks.add(remaining)
  2983.                 break
  2984.             }
  2985.            
  2986.             // Find a good split point
  2987.             var splitAt = maxLen
  2988.             // Start from maxLen and work backwards to find a character boundary
  2989.             while (splitAt > 0 && remaining.substring(0, minOf(splitAt, remaining.length))
  2990.                     .toByteArray(Charsets.UTF_8).size > maxLen) {
  2991.                 splitAt--
  2992.             }
  2993.            
  2994.             if (splitAt == 0) splitAt = 1  // Ensure we make progress
  2995.             splitAt = minOf(splitAt, remaining.length)
  2996.            
  2997.             // Try to split on a word boundary (space)
  2998.             val chunk = remaining.substring(0, splitAt)
  2999.             val lastSpace = chunk.lastIndexOf(' ')
  3000.             val actualSplit = if (lastSpace > splitAt / 2) lastSpace else splitAt
  3001.            
  3002.             chunks.add(remaining.substring(0, actualSplit).trim())
  3003.             remaining = remaining.substring(actualSplit).trim()
  3004.         }
  3005.        
  3006.         return chunks.filter { it.isNotEmpty() }
  3007.     }
  3008.  
  3009.     fun joinChannel(channel: String) {
  3010.         val netId = _state.value.activeNetworkId ?: return
  3011.         val rt = runtimes[netId] ?: return
  3012.         viewModelScope.launch { rt.client.sendRaw("JOIN $channel") }
  3013.         openBuffer(resolveBufferKey(netId, channel))
  3014.     }
  3015.  
  3016.     fun requestList() {
  3017.         val netId = _state.value.activeNetworkId ?: return
  3018.         val rt = runtimes[netId] ?: return
  3019.         viewModelScope.launch {
  3020.             _channelListBuffer.clear()
  3021.             _state.value = _state.value.copy(listInProgress = true, channelDirectory = emptyList())
  3022.             rt.client.sendRaw("LIST")
  3023.         }
  3024.     }
  3025.  
  3026.     fun whois(nick: String) {
  3027.         // Route through the slash-command path so WHOIS replies can be routed
  3028.         // back to the current buffer (channel/query) instead of always the server buffer.
  3029.         sendInput("/whois $nick")
  3030.     }
  3031.  
  3032.     // Ignore list
  3033.  
  3034.     private fun canonicalIgnoreNick(raw: String): String? {
  3035.         val t = raw.trim()
  3036.         if (t.isBlank()) return null
  3037.         val token = t.split(Regex("\\s+"), limit = 2).firstOrNull()?.trim() ?: return null
  3038.         // If it looks like a mask (nick!user@host or *!*@host), keep it as-is (trimmed).
  3039.         if (token.contains('!') || token.contains('@') || token.contains('*') || token.contains('?')) {
  3040.             return token.takeIf { it.isNotBlank() }
  3041.         }
  3042.         // Plain nick: strip mode prefix and trailing punctuation.
  3043.         val base = token.trimEnd(':', ',').trimStart('~','&','@','%','+')
  3044.         if (base.isBlank() || base == "." || base == "..") return null
  3045.         val cleaned = base.replace(Regex("[\u0000-\u001F\u007F]"), "").trim()
  3046.         return cleaned.takeIf { it.isNotBlank() }
  3047.     }
  3048.  
  3049.     private fun isNickIgnored(netId: String, nick: String?, userHost: String? = null): Boolean {
  3050.         val n = nick?.trim().takeIf { !it.isNullOrBlank() } ?: return false
  3051.         val base = n.trimStart('~','&','@','%','+')
  3052.         val list = _state.value.networks.firstOrNull { it.id == netId }?.ignoredNicks.orEmpty()
  3053.         // Build full nick!user@host string for mask matching if we have it.
  3054.         val fullMask = if (userHost != null) "$base!$userHost" else base
  3055.         return list.any { pattern ->
  3056.             if (pattern.contains('*') || pattern.contains('?') || pattern.contains('!')) {
  3057.                 // Wildcard pattern — convert IRC glob to regex and match against full mask.
  3058.                 matchIrcGlob(pattern, fullMask)
  3059.             } else {
  3060.                 // Simple exact nick match (original behaviour).
  3061.                 pattern.equals(base, ignoreCase = true)
  3062.             }
  3063.         }
  3064.     }
  3065.  
  3066.     /** Match an IRC-style glob pattern (*, ?) against [input], case-insensitive. */
  3067.     private fun matchIrcGlob(pattern: String, input: String): Boolean {
  3068.         val regex = buildString {
  3069.             append("(?i)\\A")
  3070.             for (ch in pattern) {
  3071.                 when (ch) {
  3072.                     '*' -> append(".*")
  3073.                     '?' -> append(".")
  3074.                     else -> append(Regex.escape(ch.toString()))
  3075.                 }
  3076.             }
  3077.             append("\\z")
  3078.         }
  3079.         return Regex(regex).containsMatchIn(input)
  3080.     }
  3081.  
  3082.     private fun updateNetworkInState(updated: com.boxlabs.hexdroid.data.NetworkProfile) {
  3083.         val st = _state.value
  3084.         val next = st.networks.map { if (it.id == updated.id) updated else it }
  3085.         _state.value = st.copy(networks = next)
  3086.     }
  3087.  
  3088.     fun ignoreNick(netId: String, nick: String) {
  3089.         val base = canonicalIgnoreNick(nick) ?: return
  3090.         val st = _state.value
  3091.         val net = st.networks.firstOrNull { it.id == netId } ?: return
  3092.         val nextList = (net.ignoredNicks + base)
  3093.             .map { it.trim() }
  3094.             .filter { it.isNotBlank() }
  3095.             .distinctBy { it.lowercase() }
  3096.         val updated = net.copy(ignoredNicks = nextList)
  3097.         updateNetworkInState(updated)
  3098.         viewModelScope.launch { repo.upsertNetwork(updated) }
  3099.         val sel = _state.value.selectedBuffer
  3100.         val (selNet, _) = splitKey(sel)
  3101.         val dest = if (sel.isNotBlank() && selNet == netId) sel else bufKey(netId, "*server*")
  3102.         append(dest, from = null, text = "*** Ignoring $base", isLocal = true, doNotify = false)
  3103.     }
  3104.  
  3105.     fun unignoreNick(netId: String, nick: String) {
  3106.         val base = canonicalIgnoreNick(nick) ?: return
  3107.         val st = _state.value
  3108.         val net = st.networks.firstOrNull { it.id == netId } ?: return
  3109.         val nextList = net.ignoredNicks.filterNot { it.equals(base, ignoreCase = true) }
  3110.         val updated = net.copy(ignoredNicks = nextList)
  3111.         updateNetworkInState(updated)
  3112.         viewModelScope.launch { repo.upsertNetwork(updated) }
  3113.         val sel = _state.value.selectedBuffer
  3114.         val (selNet, _) = splitKey(sel)
  3115.         val dest = if (sel.isNotBlank() && selNet == netId) sel else bufKey(netId, "*server*")
  3116.         append(dest, from = null, text = "*** Unignored $base", isLocal = true, doNotify = false)
  3117.     }
  3118.  
  3119.     fun openIgnoreList() { goTo(AppScreen.IGNORE) }
  3120.     // IRC event handling
  3121.  
  3122.     private fun handleEvent(netId: String, ev: IrcEvent) {
  3123.         when (ev) {
  3124.             is IrcEvent.Status -> {
  3125.                 setNetConn(netId) { it.copy(status = ev.text) }
  3126.                 append(bufKey(netId, "*server*"), from = null, text = "*** ${ev.text}", doNotify = false)
  3127.             }
  3128.             is IrcEvent.Connected -> {
  3129.                 manualDisconnecting.remove(netId)
  3130.                 reconnectAttempts.remove(netId)  // Reset reconnect backoff on successful connection
  3131.                 runtimes[netId]?.apply { suppressMotd = _state.value.settings.hideMotdOnConnect; manualMotdAtMs = 0L }
  3132.                 autoReconnectJobs.remove(netId)?.cancel()
  3133.                 setNetConn(netId) {
  3134.                     it.copy(connecting = false, connected = true, status = "Connected to ${ev.server}", lagMs = null)
  3135.                 }
  3136.                 if (_state.value.activeNetworkId == netId) updateConnectionNotification("Connected")
  3137.             }
  3138.             is IrcEvent.LagUpdated -> {
  3139.                 // Skip the startService() IPC call when backgrounded - the notification
  3140.                 // text never shows lag values so there's nothing to update.
  3141.                 // Still update state so the lag bar is fresh when the user returns.
  3142.                 if (!AppVisibility.isForeground) {
  3143.                     _state.update { st ->
  3144.                         val old = st.connections[netId] ?: NetConnState()
  3145.                         val newConns = st.connections + (netId to old.copy(lagMs = ev.lagMs))
  3146.                         syncActiveNetworkSummary(st.copy(connections = newConns))
  3147.                     }
  3148.                 } else {
  3149.                     setNetConn(netId) { it.copy(lagMs = ev.lagMs) }
  3150.                 }
  3151.             }
  3152.             is IrcEvent.Disconnected -> {
  3153.                 val r = ev.reason?.trim()
  3154.                 val pretty = when {
  3155.                     r.isNullOrBlank() -> "Disconnected"
  3156.                     r.equals("Client disconnect", ignoreCase = true) -> "Disconnected"
  3157.                     r.equals("EOF", ignoreCase = true) -> "Disconnected"
  3158.                     r.equals("socket closed", ignoreCase = true) -> "Disconnected"
  3159.                     else -> "Disconnected: $r"
  3160.                 }
  3161.                 append(bufKey(netId, "*server*"), from = null, text = "*** $pretty", doNotify = false)
  3162.                 setNetConn(netId) { it.copy(connecting = false, connected = false, status = pretty, lagMs = null) }
  3163.                 if (_state.value.activeNetworkId == netId) clearConnectionNotification()
  3164.                 cleanupNetworkMaps(netId)
  3165.  
  3166.                 // Flap detection: count ping-timeout disconnects within the window.
  3167.                 val isPingTimeout = r != null && (
  3168.                     r.contains("ping timeout", ignoreCase = true) ||
  3169.                     r.contains("ping time out", ignoreCase = true) ||
  3170.                     r.contains("connection reset", ignoreCase = true) ||
  3171.                     r.contains("SocketTimeout", ignoreCase = true) ||
  3172.                     r.contains("read timed out", ignoreCase = true)
  3173.                 )
  3174.                 if (isPingTimeout) {
  3175.                     val now = System.currentTimeMillis()
  3176.                     val q = pingTimeoutTimestamps.getOrPut(netId) { ArrayDeque() }
  3177.                     q.addLast(now)
  3178.                     // Drop events older than the flap window.
  3179.                     while (q.isNotEmpty() && now - q.first() > ConnectionConstants.FLAP_WINDOW_MS) {
  3180.                         q.removeFirst()
  3181.                     }
  3182.                     if (q.size >= ConnectionConstants.FLAP_THRESHOLD && !flapPaused.contains(netId)) {
  3183.                         markFlapPaused(netId)
  3184.                         val serverKey = bufKey(netId, "*server*")
  3185.                         append(serverKey, from = null, text =
  3186.                             "*** ⚠ Connection is unstable - ${q.size} ping timeouts in the last " +
  3187.                             "${ConnectionConstants.FLAP_WINDOW_MS / 60000} minutes. " +
  3188.                             "Auto-reconnect paused to avoid flooding the server. " +
  3189.                             "Tap 'Reconnect' to try again when your network is stable.",
  3190.                             doNotify = false)
  3191.                         setNetConn(netId) { it.copy(status = "Unstable - reconnect manually") }
  3192.                     }
  3193.                 }
  3194.  
  3195.                 val wasManual = manualDisconnecting.remove(netId)
  3196.                 if (wasManual && !desiredConnected.contains(netId)) return
  3197.  
  3198.                 // Don't auto-reconnect if flap detection has paused this network.
  3199.                 if (flapPaused.contains(netId)) {
  3200.                     setNetConn(netId) { it.copy(status = "Unstable - reconnect manually") }
  3201.                     return
  3202.                 }
  3203.  
  3204.                 if (desiredConnected.contains(netId)) scheduleAutoReconnect(netId)
  3205.             }
  3206.             is IrcEvent.Error -> {
  3207.                 val msg = ev.message
  3208.                 val isConnectFail = msg.startsWith("Connect failed", ignoreCase = true) || msg.startsWith("Connection failed", ignoreCase = true)
  3209.                 append(bufKey(netId, "*server*"), from = "ERROR", text = msg, isHighlight = !isConnectFail, doNotify = !isConnectFail)
  3210.             }
  3211.  
  3212.             is IrcEvent.TlsFingerprintLearned -> {
  3213.                 // First time we see this server's TLS certificate - persist the fingerprint so
  3214.                 // future connections use TOFU pinning instead of trusting all certs blindly.
  3215.                 val fp = ev.fingerprint
  3216.                 viewModelScope.launch {
  3217.                     try {
  3218.                         repo.updateNetworkProfile(netId) { it.copy(tlsTofuFingerprint = fp) }
  3219.                         append(
  3220.                             bufKey(netId, "*server*"), from = null,
  3221.                             text = "*** TLS: Certificate fingerprint learned and pinned (TOFU). " +
  3222.                                    "Future connections will verify: $fp",
  3223.                             doNotify = false
  3224.                         )
  3225.                     } catch (t: Throwable) {
  3226.                         append(bufKey(netId, "*server*"), from = null,
  3227.                             text = "*** TLS: Could not persist certificate fingerprint: ${t.message}", doNotify = false)
  3228.                     }
  3229.                 }
  3230.             }
  3231.  
  3232.             is IrcEvent.TlsFingerprintChanged -> {
  3233.                 // The server is presenting a DIFFERENT certificate than the one we pinned.
  3234.                 // This is a serious warning - could be a certificate rotation or a MITM attack.
  3235.                 append(
  3236.                     bufKey(netId, "*server*"), from = "TLS WARNING", isHighlight = true,
  3237.                     text = "⚠️  Server certificate fingerprint has CHANGED! " +
  3238.                            "Expected: ${ev.stored}  •  Got: ${ev.actual}  — " +
  3239.                            "Connection refused. If this is a legitimate cert renewal, go to " +
  3240.                            "Network Settings → Allow invalid certificates and reconnect once to re-pin."
  3241.                 )
  3242.             }
  3243.             is IrcEvent.ServerLine -> {
  3244.                 val stNow = _state.value
  3245.                 if (stNow.settings.loggingEnabled && stNow.settings.logServerBuffer) {
  3246.                     val netName = stNow.networks.firstOrNull { it.id == netId }?.name ?: netId
  3247.                     val ts = System.currentTimeMillis()
  3248.                     val line = ev.line
  3249.                     val logLine = formatLogLine(ts, from = null, text = line, isAction = false)
  3250.                     logs.append(netName, "*server*", logLine, stNow.settings.logFolderUri)
  3251.                 }
  3252.                 // PONG handling and lag measurement are done in IrcCore; LagUpdated events update the UI.
  3253.             }
  3254.             is IrcEvent.ServerText -> {
  3255.                 val code = ev.code
  3256.                 val rt = runtimes[netId]
  3257.                 val motdCodes = setOf("375","372","376","422")
  3258.                 val hideMotd = _state.value.settings.hideMotdOnConnect
  3259.                 val now = System.currentTimeMillis()
  3260.                 val manualMotdActive = rt?.manualMotdAtMs?.let { it != 0L && now - it < 60_000L } == true
  3261.                 // Never suppress bouncer MOTD - it contains useful status (e.g. which upstream networks are connected).
  3262.                 val isBouncer = _state.value.networks.firstOrNull { it.id == netId }?.isBouncer == true
  3263.                 if (!manualMotdActive && hideMotd && !isBouncer && code != null && code in motdCodes) {
  3264.                     // Some connect paths can build the runtime before settings are loaded; re-arm suppression here too.
  3265.                     if (rt != null && !rt.suppressMotd) rt.suppressMotd = true
  3266.                     if (rt?.suppressMotd != false) {
  3267.                         // Suppress automatic MOTD output on connect if configured
  3268.                         if (code == "376" || code == "422") rt?.suppressMotd = false
  3269.                         return
  3270.                     }
  3271.                 }
  3272.                 val targetKey = if (!ev.bufferName.isNullOrBlank() && ev.bufferName != "*server*") {
  3273.                     resolveBufferKey(netId, ev.bufferName)
  3274.                 } else {
  3275.                     bufKey(netId, "*server*")
  3276.                 }
  3277.                 val isMotdLine = code == "372"
  3278.                 append(targetKey, from = null, text = ev.text, doNotify = false, isMotd = isMotdLine)
  3279.  
  3280. if (code == "442") {
  3281.     // Not on channel. If this was triggered by the UI close-buffer flow, remove the buffer anyway.
  3282.     val chan = Regex("([#&+!][^\\s]+)").find(ev.text)?.groupValues?.getOrNull(1)
  3283.     val key = if (chan != null) {
  3284.         popPendingCloseForChannel(netId, chan)
  3285.     } else {
  3286.         pendingCloseAfterPart.firstOrNull { it.startsWith("$netId::") }?.also { pendingCloseAfterPart.remove(it) }
  3287.     }
  3288.  
  3289.     if (key != null) {
  3290.         chanNickCase.remove(key)
  3291.         chanNickStatus.remove(key)
  3292.         removeBuffer(key)
  3293.     }
  3294. }
  3295.                 if (code == "376" || code == "422") {
  3296.                     // End of MOTD (or no MOTD) - stop suppressing for this session
  3297.                     if (rt != null) { rt.suppressMotd = false; rt.manualMotdAtMs = 0L }
  3298.                 }
  3299.             }
  3300.  
  3301.             is IrcEvent.JoinError -> {
  3302.                 val st = _state.value
  3303.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3304.                 val dest = when {
  3305.                     st.buffers.containsKey(chanKey) -> chanKey
  3306.                     splitKey(st.selectedBuffer).first == netId -> st.selectedBuffer
  3307.                     else -> bufKey(netId, "*server*")
  3308.                 }
  3309.                 append(dest, from = null, text = "*** ${ev.message}", doNotify = false)
  3310.             }
  3311.             is IrcEvent.ChannelModeIs -> {
  3312.                 val st = _state.value
  3313.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3314.                 val dest = if (st.buffers.containsKey(chanKey)) chanKey else bufKey(netId, "*server*")
  3315.                 append(dest, from = null, text = "* Mode ${ev.channel} ${ev.modes}", doNotify = false)
  3316.                 // Store mode string so Channel Tools can show/toggle modes
  3317.                 val buf = st.buffers[chanKey]
  3318.                 if (buf != null) {
  3319.                     val modeOnly = ev.modes.split(" ").firstOrNull() ?: ev.modes
  3320.                     _state.update { it.copy(buffers = it.buffers + (chanKey to buf.copy(modeString = modeOnly))) }
  3321.                 }
  3322.             }
  3323.  
  3324.             is IrcEvent.YoureOper -> {
  3325.                 append(bufKey(netId, "*server*"), from = null, text = "*** ${ev.message}", doNotify = false)
  3326.                 setNetConn(netId) { it.copy(isIrcOper = true) }
  3327.             }
  3328.             is IrcEvent.YoureDeOpered -> {
  3329.                 setNetConn(netId) { it.copy(isIrcOper = false) }
  3330.             }
  3331.  
  3332.             is IrcEvent.BanListItem -> {
  3333.                 val st0 = _state.value
  3334.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  3335.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3336.                 ensureBuffer(chanKey)
  3337.  
  3338.                 val cur = st0.banlists[chanKey].orEmpty()
  3339.                 val nextList = (cur + BanEntry(ev.mask, ev.setBy, ev.setAtMs)).distinctBy { it.mask }
  3340.                 _state.value = syncActiveNetworkSummary(
  3341.                     st0.copy(
  3342.                         banlists = st0.banlists + (chanKey to nextList),
  3343.                         banlistLoading = st0.banlistLoading + (chanKey to true)
  3344.                     )
  3345.                 )
  3346.  
  3347.                 // Don't spam the channel buffer with every ban entry.
  3348.                 // (Users can view them via the Channel tools -> Ban list UI.)
  3349.             }
  3350.  
  3351.             is IrcEvent.BanListEnd -> {
  3352.                 val st0 = _state.value
  3353.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3354.                 _state.value = syncActiveNetworkSummary(
  3355.                     st0.copy(banlistLoading = st0.banlistLoading + (chanKey to false))
  3356.                 )
  3357.             }
  3358.  
  3359.             is IrcEvent.QuietListItem -> {
  3360.                 val st0 = _state.value
  3361.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3362.                 ensureBuffer(chanKey)
  3363.                 val cur = st0.quietlists[chanKey].orEmpty()
  3364.                 val nextList = (cur + BanEntry(ev.mask, ev.setBy, ev.setAtMs)).distinctBy { it.mask }
  3365.                 _state.value = syncActiveNetworkSummary(
  3366.                     st0.copy(
  3367.                         quietlists = st0.quietlists + (chanKey to nextList),
  3368.                         quietlistLoading = st0.quietlistLoading + (chanKey to true)
  3369.                     )
  3370.                 )
  3371.             }
  3372.  
  3373.             is IrcEvent.QuietListEnd -> {
  3374.                 val st0 = _state.value
  3375.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3376.                 _state.value = syncActiveNetworkSummary(
  3377.                     st0.copy(quietlistLoading = st0.quietlistLoading + (chanKey to false))
  3378.                 )
  3379.             }
  3380.  
  3381.             is IrcEvent.ExceptListItem -> {
  3382.                 val st0 = _state.value
  3383.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3384.                 ensureBuffer(chanKey)
  3385.                 val cur = st0.exceptlists[chanKey].orEmpty()
  3386.                 val nextList = (cur + BanEntry(ev.mask, ev.setBy, ev.setAtMs)).distinctBy { it.mask }
  3387.                 _state.value = syncActiveNetworkSummary(
  3388.                     st0.copy(
  3389.                         exceptlists = st0.exceptlists + (chanKey to nextList),
  3390.                         exceptlistLoading = st0.exceptlistLoading + (chanKey to true)
  3391.                     )
  3392.                 )
  3393.             }
  3394.  
  3395.             is IrcEvent.ExceptListEnd -> {
  3396.                 val st0 = _state.value
  3397.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3398.                 _state.value = syncActiveNetworkSummary(
  3399.                     st0.copy(exceptlistLoading = st0.exceptlistLoading + (chanKey to false))
  3400.                 )
  3401.             }
  3402.  
  3403.             is IrcEvent.InvexListItem -> {
  3404.                 val st0 = _state.value
  3405.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3406.                 ensureBuffer(chanKey)
  3407.                 val cur = st0.invexlists[chanKey].orEmpty()
  3408.                 val nextList = (cur + BanEntry(ev.mask, ev.setBy, ev.setAtMs)).distinctBy { it.mask }
  3409.                 _state.value = syncActiveNetworkSummary(
  3410.                     st0.copy(
  3411.                         invexlists = st0.invexlists + (chanKey to nextList),
  3412.                         invexlistLoading = st0.invexlistLoading + (chanKey to true)
  3413.                     )
  3414.                 )
  3415.             }
  3416.  
  3417.             is IrcEvent.InvexListEnd -> {
  3418.                 val st0 = _state.value
  3419.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3420.                 _state.value = syncActiveNetworkSummary(
  3421.                     st0.copy(invexlistLoading = st0.invexlistLoading + (chanKey to false))
  3422.                 )
  3423.             }
  3424.             is IrcEvent.ISupport -> {
  3425.                 val rt = runtimes[netId]
  3426.                 if (rt != null) {
  3427.                     rt.support = NetSupport(
  3428.                         chantypes = ev.chantypes,
  3429.                         caseMapping = ev.caseMapping,
  3430.                         prefixModes = ev.prefixModes,
  3431.                         prefixSymbols = ev.prefixSymbols,
  3432.                         statusMsg = ev.statusMsg,
  3433.                         chanModes = ev.chanModes,
  3434.                         linelen = ev.linelen
  3435.                     )
  3436.                 }
  3437.  
  3438.                 // Expose list modes to the UI so the Channel lists sheet can adapt per-ircd.
  3439.                 val listModes = ev.chanModes
  3440.                     ?.split(',')
  3441.                     ?.getOrNull(0)
  3442.                     ?.trim()
  3443.                     ?.takeIf { it.isNotBlank() }
  3444.                 if (listModes != null) {
  3445.                     setNetConn(netId) { it.copy(listModes = listModes) }
  3446.                 }
  3447.             }
  3448.  
  3449.             is IrcEvent.Registered -> {
  3450.                 runtimes[netId]?.myNick = ev.nick
  3451.                 val rt0 = runtimes[netId]
  3452.                 val hasReact = rt0 != null &&
  3453.                     (rt0.client.hasCap("message-tags") || rt0.client.hasCap("draft/message-reactions"))
  3454.                 setNetConn(netId) { it.copy(myNick = ev.nick, hasReactionSupport = hasReact) }
  3455.                 append(bufKey(netId, "*server*"), from = null, text = "*** Registered as ${ev.nick}", doNotify = false)
  3456.  
  3457.                 val rt = runtimes[netId] ?: return
  3458.                 val profile = _state.value.networks.firstOrNull { it.id == netId }
  3459.  
  3460.                 viewModelScope.launch {
  3461.                     // 1. soju bouncer: bind to a specific upstream network after registration.
  3462.                     //    BOUNCER BIND <networkId> tells soju to route subsequent traffic through
  3463.                     //    that upstream, enabling per-network connection from a single soju endpoint.
  3464.                     val bindId = rt.client.config.bouncerNetworkId
  3465.                     if (rt.client.config.isBouncer && !bindId.isNullOrBlank()) {
  3466.                         rt.client.sendRaw("BOUNCER BIND $bindId")
  3467.                     }
  3468.  
  3469.                     // 2. Service auth command (e.g. /msg NickServ IDENTIFY password)
  3470.                     //    Runs first, before autojoin, so channels with +r can be joined.
  3471.                     profile?.serviceAuthCommand?.takeIf { it.isNotBlank() }?.let { cmd ->
  3472.                         val trimmed = cmd.trim()
  3473.                         if (trimmed.startsWith("/")) {
  3474.                             // Client command aliases
  3475.                             rt.client.handleSlashCommand(trimmed.drop(1), "*server*")
  3476.                         } else {
  3477.                             // Raw IRC line
  3478.                             rt.client.sendRaw(trimmed)
  3479.                         }
  3480.                     }
  3481.  
  3482.                     // 3. Optional delay before autojoin & commands
  3483.                     //    Gives services time to identify/cloak before joining channels.
  3484.                     val delaySec = profile?.autoCommandDelaySeconds ?: 0
  3485.                     if (delaySec > 0) {
  3486.                         append(bufKey(netId, "*server*"), from = null,
  3487.                             text = "*** Waiting ${delaySec}s before auto-join & commands…", doNotify = false)
  3488.                         delay(delaySec * 1000L)
  3489.                     }
  3490.  
  3491.                     // 4. Autojoin channels (skipped for bouncers - they keep you joined server-side)
  3492.                     if (!rt.client.config.isBouncer) {
  3493.                         val aj = rt.client.config.autoJoin
  3494.                         for (c in aj) {
  3495.                             val join = if (c.key.isNullOrBlank()) "JOIN ${c.channel}" else "JOIN ${c.channel} ${c.key}"
  3496.                             rt.client.sendRaw(join)
  3497.                         }
  3498.  
  3499.                         // Rejoin channels the user joined manually (outside autoJoin) that were
  3500.                         // lost when the connection dropped.
  3501.                         for ((chan, key) in rt.manuallyJoinedChannels.toMap()) {
  3502.                             val join = if (key.isNullOrBlank()) "JOIN $chan" else "JOIN $chan $key"
  3503.                             rt.client.sendRaw(join)
  3504.                         }
  3505.                     }
  3506.  
  3507.                     // 5. Post-connect commands (one per line, like mIRC's Perform)
  3508.                     //    Supports both /slash commands and raw IRC lines.
  3509.                     //    Per-command delay: append  ;wait N  or  ;wait Ns  to a line
  3510.                     //    (e.g. "/msg NickServ identify pass ;wait 3") to pause N seconds
  3511.                     //    after that command before sending the next one.
  3512.                     profile?.autoCommandsText?.takeIf { it.isNotBlank() }?.let { text ->
  3513.                         // Trim each line so that accidental leading/trailing whitespace does not
  3514.                         // cause commands to be sent verbatim with a leading space (silent failure).
  3515.                         val waitRegex = Regex("""\s*;wait\s+(\d+)s?\s*$""", RegexOption.IGNORE_CASE)
  3516.                         val commands = text.lines()
  3517.                             .map { it.trim() }
  3518.                             .filter { it.isNotEmpty() }
  3519.                         for (rawLine in commands) {
  3520.                             // Extract optional ;wait N suffix before sending.
  3521.                             val waitMatch = waitRegex.find(rawLine)
  3522.                             val waitMs = waitMatch?.groupValues?.get(1)?.toLongOrNull()
  3523.                                 ?.coerceIn(1L, 300L)?.times(1000L) ?: 0L
  3524.                             val cmd = if (waitMatch != null) rawLine.substring(0, waitMatch.range.first).trim()
  3525.                                       else rawLine
  3526.  
  3527.                             if (cmd.isNotEmpty()) {
  3528.                                 if (cmd.startsWith("/")) {
  3529.                                     rt.client.handleSlashCommand(cmd.drop(1), "*server*")
  3530.                                 } else {
  3531.                                     rt.client.sendRaw(cmd)
  3532.                                 }
  3533.                             }
  3534.  
  3535.                             if (waitMs > 0) {
  3536.                                 append(bufKey(netId, "*server*"), from = null,
  3537.                                     text = "*** Waiting ${waitMs / 1000}s…", doNotify = false)
  3538.                                 delay(waitMs)
  3539.                             }
  3540.                         }
  3541.                     }
  3542.                 }
  3543.             }
  3544.             is IrcEvent.NickChanged -> {
  3545.                 val st0 = _state.value
  3546.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  3547.  
  3548.                 val my = st0.connections[netId]?.myNick ?: runtimes[netId]?.myNick ?: st0.myNick
  3549.                 val isMe = casefoldText(netId, ev.oldNick) == casefoldText(netId, my)
  3550.  
  3551.                 // Show nick changes in-channel (like mIRC):
  3552.                 //   * old is now known as new
  3553.                 //   * You are now known as new
  3554.                 val line = if (isMe) "* You are now known as ${ev.newNick}"
  3555.                 else "* ${ev.oldNick} is now known as ${ev.newNick}"
  3556.  
  3557.                 // Determine which channel buffers to print to.
  3558.                 // Prefer channels where we currently see the old nick in the nicklist; otherwise
  3559.                 // fall back to all joined channels for this network.
  3560.                 val affectedChannels = st0.nicklists
  3561.                     .filterKeys { it.startsWith("$netId::") }
  3562.                     .filter { (k, list) ->
  3563.                         val (_, name) = splitKey(k)
  3564.                         isChannelOnNet(netId, name) &&
  3565.                             list.any { parseNickWithPrefixes(netId, it).first.let { b -> casefoldText(netId, b) == casefoldText(netId, ev.oldNick) } }
  3566.                     }
  3567.                     .map { it.key }
  3568.  
  3569.                 val allChannelTargets = st0.buffers.keys
  3570.                     .filter { it.startsWith("$netId::") }
  3571.                     .filter { key ->
  3572.                         val (_, name) = splitKey(key)
  3573.                         isChannelOnNet(netId, name)
  3574.                     }
  3575.  
  3576.                 val targets = when {
  3577.                     affectedChannels.isNotEmpty() -> affectedChannels
  3578.                     allChannelTargets.isNotEmpty() -> allChannelTargets
  3579.                     else -> emptyList()
  3580.                 }
  3581.  
  3582.                 for (k in targets) {
  3583.                     append(
  3584.                         k,
  3585.                         from = null,
  3586.                         text = line,
  3587.                         isLocal = suppressUnread,
  3588.                         timeMs = ev.timeMs,
  3589.                         doNotify = false
  3590.                     )
  3591.                 }
  3592.  
  3593. // If we couldn't attribute this nick to any channel buffers, surface it in the server buffer.
  3594. if (targets.isEmpty()) {
  3595.     append(
  3596.         bufKey(netId, "*server*"),
  3597.         from = null,
  3598.         text = line,
  3599.         isLocal = suppressUnread,
  3600.         timeMs = ev.timeMs,
  3601.         doNotify = false
  3602.     )
  3603. }
  3604.  
  3605.                 if (!ev.isHistory) {
  3606.                     // If it's our nick, update runtime + UI connection state first.
  3607.                     if (isMe) {
  3608.                         runtimes[netId]?.myNick = ev.newNick
  3609.                         setNetConn(netId) { it.copy(myNick = ev.newNick) }
  3610.                     }
  3611.  
  3612.                     // Re-read state after appends/setNetConn so we don't overwrite newer state.
  3613.                     val st1 = _state.value
  3614.  
  3615.                    
  3616. // Update nicklists for this network (multi-status safe).
  3617. moveNickAcrossChannels(netId, ev.oldNick, ev.newNick)
  3618.  
  3619. // Transfer away state from old nick to new nick.
  3620. val awayMap = nickAwayState[netId]
  3621. if (awayMap != null) {
  3622.     val oldFold = casefoldText(netId, ev.oldNick)
  3623.     val newFold = casefoldText(netId, ev.newNick)
  3624.     awayMap.remove(oldFold)?.let { awayMap[newFold] = it }
  3625. }
  3626.  
  3627. val updatedNicklists = st1.nicklists.mapValues { (k, list) ->
  3628.     val (kid, _) = splitKey(k)
  3629.     if (kid != netId) list
  3630.     else rebuildNicklist(netId, k)
  3631. }
  3632.  
  3633. // Drop the old nick's typing indicator from all channel buffers on this network.
  3634. // The new nick hasn't sent a TAGMSG typing event yet, so don't carry it over.
  3635. val updatedBufs = st1.buffers.mapValues { (k, buf) ->
  3636.     if (k.startsWith("$netId::") && ev.oldNick in buf.typingNicks)
  3637.         buf.copy(typingNicks = buf.typingNicks - ev.oldNick)
  3638.     else buf
  3639. }
  3640.  
  3641. var next = st1.copy(nicklists = updatedNicklists, buffers = updatedBufs)
  3642.                     // Rename private-message buffer key if present.
  3643.                     val oldKey = bufKey(netId, ev.oldNick)
  3644.                     val newKey = bufKey(netId, ev.newNick)
  3645.                     if (next.buffers.containsKey(oldKey) && !next.buffers.containsKey(newKey)) {
  3646.                         val b = next.buffers[oldKey]
  3647.                         if (b != null) next = next.copy(
  3648.                             buffers = (next.buffers - oldKey) + (newKey to b.copy(name = newKey)),
  3649.                             selectedBuffer = if (next.selectedBuffer == oldKey) newKey else next.selectedBuffer
  3650.                         )
  3651.                     }
  3652.  
  3653.                     _state.value = syncActiveNetworkSummary(next)
  3654.                 }
  3655.             }
  3656.  
  3657.             is IrcEvent.DccOfferEvent -> {
  3658.                 if (isNickIgnored(netId, ev.offer.from)) return
  3659.  
  3660.                 val offer0 = ev.offer.copy(netId = netId)
  3661.  
  3662.                 // If this is a passive/reverse DCC reply for one of our outgoing sends, consume it.
  3663.                 val baseName = offer0.filename.substringAfterLast('/').substringAfterLast('\\')
  3664.                 val token = offer0.token
  3665.                 if (token != null) {
  3666.                     val pending = pendingPassiveDccSends[token]
  3667.                     if (pending != null
  3668.                         && pending.target.equals(offer0.from, ignoreCase = true)
  3669.                         && pending.filename == baseName
  3670.                         && offer0.port > 0
  3671.                         && (offer0.size == 0L || pending.size == 0L || offer0.size == pending.size)
  3672.                     ) {
  3673.                         pendingPassiveDccSends.remove(token)
  3674.                         pending.reply.complete(offer0)
  3675.                         return
  3676.                     }
  3677.                 } else {
  3678.                     // Fallback: some clients (or bouncers) reply without a token; match by target+filename(+size).
  3679.                     val match = pendingPassiveDccSends.entries.firstOrNull { (_, p) ->
  3680.                         p.target.equals(offer0.from, ignoreCase = true)
  3681.                             && p.filename == baseName
  3682.                             && offer0.port > 0
  3683.                             && (offer0.size == 0L || p.size == 0L || offer0.size == p.size)
  3684.                     }
  3685.                     if (match != null) {
  3686.                         pendingPassiveDccSends.remove(match.key)
  3687.                         match.value.reply.complete(offer0)
  3688.                         return
  3689.                     }
  3690.                 }
  3691.  
  3692.                 val st = _state.value
  3693.                 _state.value = st.copy(dccOffers = st.dccOffers + offer0)
  3694.                 append(bufKey(netId, "*server*"), from = null, text = "*** Incoming DCC file offer from ${offer0.from}: ${offer0.filename} (Transfers screen to accept)")
  3695.                 if (st.settings.notificationsEnabled) {
  3696.                     notifier.notifyDccIncomingFile(netId, offer0.from, baseName)
  3697.                 }
  3698.             }
  3699.  
  3700.             is IrcEvent.DccChatOfferEvent -> {
  3701.                 if (isNickIgnored(netId, ev.offer.from)) return
  3702.  
  3703.                 val offer0 = ev.offer.copy(netId = netId)
  3704.                 val st = _state.value
  3705.                 // De-dupe by peer + endpoint.
  3706.                 val exists = st.dccChatOffers.any {
  3707.                     it.netId == netId && it.from.equals(offer0.from, ignoreCase = true) && it.ip == offer0.ip && it.port == offer0.port
  3708.                 }
  3709.                 if (!exists) {
  3710.                     _state.value = st.copy(dccChatOffers = st.dccChatOffers + offer0)
  3711.                     // Create the DCC chat buffer immediately so the user can see and act on the
  3712.                     // offer without having to navigate to the Transfers screen.
  3713.                     val chatKey = dccChatBufferKey(netId, offer0.from)
  3714.                     ensureBuffer(chatKey)
  3715.                     append(
  3716.                         bufKey(netId, "*server*"),
  3717.                         from = null,
  3718.                         text = "*** Incoming DCC CHAT from ${offer0.from} — tap 'DCCCHAT:${offer0.from}' buffer, or open Transfers to accept",
  3719.                         doNotify = false
  3720.                     )
  3721.                     // Show the offer inline inside the dedicated buffer with a clear prompt.
  3722.                     append(
  3723.                         chatKey,
  3724.                         from = null,
  3725.                         text = "*** DCC CHAT offer from ${offer0.from} (${offer0.ip}:${offer0.port}). Use /dcc accept ${offer0.from} or open Transfers to accept.",
  3726.                         doNotify = false
  3727.                     )
  3728.                     if (st.settings.notificationsEnabled) {
  3729.                         // Pass the DCC chat buffer key so the notification deep-links directly
  3730.                         // into the buffer (where Accept/Reject inline actions live), rather than
  3731.                         // requiring the user to navigate to the generic Transfers screen first.
  3732.                         val chatBufKey = dccChatBufferKey(netId, offer0.from)
  3733.                         notifier.notifyDccIncomingChat(netId, offer0.from, dccBufferKey = chatBufKey)
  3734.                     }
  3735.                 }
  3736.             }
  3737.  
  3738.             is IrcEvent.NotOnChannel -> {
  3739.                 val chan = normalizeIncomingBufferName(netId, ev.channel)
  3740.                 val pendingKey = popPendingCloseForChannel(netId, chan)
  3741.                 if (pendingKey != null) {
  3742.                     // We tried to part/close a channel we're not in; drop the buffer anyway.
  3743.                     removeBuffer(pendingKey)
  3744.                     append(bufKey(netId, "*server*"), from = null, text = "*** Closed buffer $chan (not on channel)", doNotify = false)
  3745.                 }
  3746.             }
  3747.             is IrcEvent.ChatMessage -> {
  3748.                 val my = _state.value.connections[netId]?.myNick ?: runtimes[netId]?.myNick ?: _state.value.myNick
  3749.                 val fromMe = ev.from.equals(my, ignoreCase = true)
  3750.                 if (!fromMe && isNickIgnored(netId, ev.from)) return
  3751.                 val st = _state.value
  3752.                 val suppressUnread = ev.isHistory && !st.settings.ircHistoryCountsAsUnread
  3753.                 val allowNotify = if (ev.isHistory) st.settings.ircHistoryTriggersNotifications else true
  3754.                 val targetKey = resolveIncomingBufferKey(netId, ev.target)
  3755.  
  3756.                 if (!ev.isHistory && fromMe && consumeEchoIfMatch(netId, targetKey, ev.text, ev.isAction)) return
  3757.  
  3758.                 ensureBuffer(targetKey)
  3759.                 // Clear this nick's typing indicator when they send a message (implicit "done").
  3760.                 if (!fromMe) {
  3761.                     _state.update { st ->
  3762.                         val buf = st.buffers[targetKey]
  3763.                         if (buf != null && ev.from in buf.typingNicks) {
  3764.                             st.copy(buffers = st.buffers + (targetKey to buf.copy(typingNicks = buf.typingNicks - ev.from)))
  3765.                         } else st
  3766.                     }
  3767.                 }
  3768.                 val highlight = if (fromMe) false else isHighlight(netId, ev.text, ev.isPrivate)
  3769.                 append(
  3770.                     targetKey,
  3771.                     from = ev.from,
  3772.                     text = ev.text,
  3773.                     isAction = ev.isAction,
  3774.                     isHighlight = highlight,
  3775.                     isPrivate = ev.isPrivate,
  3776.                     isLocal = fromMe || suppressUnread,
  3777.                     isHistory = ev.isHistory,
  3778.                     timeMs = ev.timeMs,
  3779.                     doNotify = allowNotify,
  3780.                     msgId = ev.msgId,
  3781.                     replyToMsgId = ev.replyToMsgId,
  3782.                 )
  3783.             }
  3784.             is IrcEvent.Notice -> {
  3785.                 val st = _state.value
  3786.                 val suppressUnread = ev.isHistory && !st.settings.ircHistoryCountsAsUnread
  3787.                 if (!ev.isServer && isNickIgnored(netId, ev.from)) return
  3788.                 val normTarget0 = normalizeIncomingBufferName(netId, ev.target)
  3789.                 val normTarget = stripStatusMsgPrefix(netId, normTarget0)
  3790.                 val isChanTarget = isChannelOnNet(netId, normTarget)
  3791.  
  3792.                 // Route notices to the current buffer on this network (or *server*)
  3793.                 // instead of spawning a new buffer for services like NickServ.
  3794.                 val destKey = when {
  3795.                     ev.isServer -> bufKey(netId, "*server*")
  3796.                     isChanTarget -> resolveBufferKey(netId, normTarget)
  3797.                     else -> {
  3798.                         // If the sender is in exactly one channel we're in, route there.
  3799.                         // This handles bots that send a notice to our nick on join (e.g. ChanServ
  3800.                         // greeting), which arrive before or just after the buffer is selected.
  3801.                         val senderFold = casefoldText(netId, ev.from)
  3802.                         val sharedChannels = st.nicklists.entries
  3803.                             .filter { (key, list) ->
  3804.                                 key.startsWith("$netId::") &&
  3805.                                 list.any { entry ->
  3806.                                     casefoldText(netId, parseNickWithPrefixes(netId, entry).first) == senderFold
  3807.                                 }
  3808.                             }
  3809.                             .map { it.key }
  3810.  
  3811.                         when {
  3812.                             // Sender is in exactly one channel - route there unambiguously.
  3813.                             sharedChannels.size == 1 -> sharedChannels.first()
  3814.                             // Sender is in multiple shared channels - prefer the currently
  3815.                             // selected buffer if it's one of them, otherwise *server*.
  3816.                             sharedChannels.size > 1 -> {
  3817.                                 val sel = st.selectedBuffer
  3818.                                 if (sel in sharedChannels) sel else bufKey(netId, "*server*")
  3819.                             }
  3820.                             // Sender not in any known channel - use current selected channel
  3821.                             // buffer on this network if available, otherwise *server*.
  3822.                             else -> {
  3823.                                 val sel = st.selectedBuffer
  3824.                                 val (selNet, selBuf) = splitKey(sel)
  3825.                                 if (sel.isNotBlank() && selNet == netId && isChannelOnNet(netId, selBuf)) sel
  3826.                                 else bufKey(netId, "*server*")
  3827.                             }
  3828.                         }
  3829.                     }
  3830.                 }
  3831.  
  3832.                 ensureBuffer(destKey)
  3833.                 append(
  3834.                     destKey,
  3835.                     from = null,
  3836.                     text = "* <${ev.from}> ${ev.text}",
  3837.                     isLocal = suppressUnread,
  3838.                     timeMs = ev.timeMs,
  3839.                     doNotify = false,
  3840.                     msgId = ev.msgId
  3841.                 )
  3842.             }
  3843.  
  3844.             is IrcEvent.CtcpReply -> {
  3845.                 // Display CTCP replies in the current buffer or server buffer
  3846.                 val st = _state.value
  3847.                 val sel = st.selectedBuffer
  3848.                 val (selNet, _) = splitKey(sel)
  3849.                 val destKey = if (sel.isNotBlank() && selNet == netId) sel else bufKey(netId, "*server*")
  3850.                 ensureBuffer(destKey)
  3851.                
  3852.                 val text = when (ev.command.uppercase()) {
  3853.                     "PING" -> {
  3854.                         // Calculate round-trip time if args is a timestamp we sent
  3855.                         // Our timestamps are 13-digit millisecond values from System.currentTimeMillis()
  3856.                         val sent = ev.args.trim().toLongOrNull()
  3857.                         val now = System.currentTimeMillis()
  3858.                         if (sent != null && sent > 1000000000000L && sent < now + 60000) {
  3859.                             // Looks like a valid recent timestamp
  3860.                             val rtt = now - sent
  3861.                             "*** CTCP PING reply from ${ev.from}: ${rtt}ms"
  3862.                         } else {
  3863.                             // Not our timestamp format, just show raw
  3864.                             "*** CTCP PING reply from ${ev.from}: ${ev.args}"
  3865.                         }
  3866.                     }
  3867.                     else -> "*** CTCP ${ev.command} reply from ${ev.from}: ${ev.args}"
  3868.                 }
  3869.                 append(destKey, from = null, text = text, isLocal = true, timeMs = ev.timeMs, doNotify = false)
  3870.             }
  3871.  
  3872.             is IrcEvent.ChannelModeLine -> {
  3873.                 val st = _state.value
  3874.                 val suppressUnread = ev.isHistory && !st.settings.ircHistoryCountsAsUnread
  3875.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3876.                 ensureBuffer(chanKey)
  3877.                 append(chanKey, from = null, text = ev.line, isLocal = suppressUnread, timeMs = ev.timeMs, doNotify = false)
  3878.             }
  3879.  
  3880.             is IrcEvent.Names -> {
  3881.                                 // Treat NAMES as a bounded snapshot (353...366). Even if we didn't explicitly request it,
  3882.                                 // servers send NAMES after JOIN and some bouncers can replay it. We accumulate until
  3883.                                 // NamesEnd then replace the channel's userlist.
  3884.                                         val rt = runtimes[netId]
  3885.                                         if (rt == null) {
  3886.                                                 // Network is no longer active; ignore.
  3887.                                         } else {
  3888.                                 val keyFold = namesKeyFold(ev.channel)
  3889.                                                 val existing = rt.namesRequests[keyFold]
  3890.                                                 if (existing != null) {
  3891.                                                         existing.names.addAll(ev.names)
  3892.                                                 } else {
  3893.                                                         val chanKey = resolveBufferKey(netId, ev.channel)
  3894.                                                         ensureBuffer(chanKey)
  3895.                                                         val nr = NamesRequest(replyBufferKey = chanKey, printToBuffer = false)
  3896.                                                         nr.names.addAll(ev.names)
  3897.                                                         rt.namesRequests[keyFold] = nr
  3898.                                                 }
  3899.                                         }
  3900.             }
  3901.  
  3902.             is IrcEvent.NamesEnd -> {
  3903.                 val rt = runtimes[netId]
  3904.                 val keyFold = namesKeyFold(ev.channel)
  3905.                 val req = rt?.namesRequests?.remove(keyFold)
  3906.                 if (req != null) {
  3907.                     val chanKey = resolveBufferKey(netId, ev.channel)
  3908.                     ensureBuffer(chanKey)
  3909.  
  3910.                     // Guard: some servers/bouncers can send EndOfNames without any 353 lines (or with partial output).
  3911.                     // Don't wipe a populated nicklist in that case.
  3912.                     val st1 = _state.value
  3913.                     val currentSize = st1.nicklists[chanKey]?.size ?: 0
  3914.                     val incomingSize = req.names.size
  3915.                     val looksBogus = (incomingSize == 0 && currentSize > 0) ||
  3916.                         (currentSize >= 5 && incomingSize < 3)
  3917.                     if (!looksBogus) {
  3918.                         applyNamesSnapshot(netId, chanKey, req.names.toList())
  3919.                     }
  3920.  
  3921.                     val names = rebuildNicklist(netId, chanKey)
  3922.                     if (req.printToBuffer) {
  3923.                         appendNamesList(req.replyBufferKey, ev.channel, names)
  3924.                     }
  3925.                 }
  3926.             }
  3927.  
  3928.             is IrcEvent.Joined -> {
  3929.                 val st0 = _state.value
  3930.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  3931.  
  3932.                 val chanKey = resolveBufferKey(netId, ev.channel)
  3933.                 ensureBuffer(chanKey)
  3934.  
  3935.                 if (!st0.settings.hideJoinPartQuit) {
  3936.                     val myNick = st0.connections[netId]?.myNick ?: st0.myNick
  3937.                     val msg = if (ev.nick.equals(myNick, ignoreCase = true)) {
  3938.                         "* Now talking on ${ev.channel}"
  3939.                     } else {
  3940.                         val host = ev.userHost ?: "*!*@*"
  3941.                         // extended-join: include account name if logged in
  3942.                         val accountSuffix = ev.account?.let { " [logged in as $it]" } ?: ""
  3943.                         "* ${ev.nick} ($host) has joined ${ev.channel}$accountSuffix"
  3944.                     }
  3945.                     append(
  3946.                         chanKey,
  3947.                         from = null,
  3948.                         text = msg,
  3949.                         isLocal = suppressUnread,
  3950.                         timeMs = ev.timeMs,
  3951.                         doNotify = false
  3952.                     )
  3953.                 }
  3954.  
  3955.                
  3956.                 val myNickNow = st0.connections[netId]?.myNick ?: st0.myNick
  3957.                 val isMeNow = casefoldText(netId, ev.nick) == casefoldText(netId, myNickNow)
  3958.  
  3959.                 if (isMeNow || shouldAffectLiveState(ev.isHistory, ev.timeMs)) {
  3960.                     // Re-read state after append/ensureBuffer so we don't overwrite newly appended messages.
  3961.                     val st1 = _state.value
  3962.  
  3963.                     upsertNickInChannel(netId, chanKey, baseNick = ev.nick)
  3964.                     val updated = rebuildNicklist(netId, chanKey)
  3965.  
  3966.                     val myNick = st1.connections[netId]?.myNick ?: st1.myNick
  3967.                     val isMe = casefoldText(netId, ev.nick) == casefoldText(netId, myNick)
  3968.  
  3969.                     // On self-join, request a fresh NAMES snapshot so the nicklist includes users who were already in the channel.
  3970.                     // Don't print it to the buffer (this is an automatic refresh, not an explicit /names).
  3971.                     if (isMe) {
  3972.                         val rt = runtimes[netId]
  3973.                         val keyFold = namesKeyFold(ev.channel)
  3974.                         if (rt != null && !rt.namesRequests.containsKey(keyFold)) {
  3975.                             rt.namesRequests[keyFold] = NamesRequest(replyBufferKey = chanKey, printToBuffer = false)
  3976.                             viewModelScope.launch { runCatching { rt.client.sendRaw("NAMES ${ev.channel}") } }
  3977.                         }
  3978.  
  3979.                         // Track for reconnect rejoin if not already covered by autoJoin.
  3980.                         // Skip history/playback - we only care about live self-joins.
  3981.                         if (!ev.isHistory && rt != null) {
  3982.                             val profile = st1.networks.firstOrNull { it.id == netId }
  3983.                             val isAutoJoin = profile?.autoJoin?.any {
  3984.                                 casefoldText(netId, it.channel.split(",")[0].trim()) == casefoldText(netId, ev.channel)
  3985.                             } == true
  3986.                             if (!isAutoJoin) {
  3987.                                 // Channel key is not available from the JOIN event - store null.
  3988.                                 // The user will be prompted by the server on reconnect if +k is still set.
  3989.                                 rt.manuallyJoinedChannels[ev.channel] = null
  3990.                             }
  3991.                         }
  3992.                     }
  3993.                     val shouldSwitch =
  3994.                         isMe &&
  3995.                             st1.activeNetworkId == netId &&
  3996.                             (st1.screen == AppScreen.CHAT || st1.screen == AppScreen.NETWORKS)
  3997.  
  3998.                     if (shouldSwitch) {
  3999.                         val leaving = st1.selectedBuffer
  4000.                         if (leaving.isNotBlank() && leaving != chanKey) stampReadMarker(leaving)
  4001.                     }
  4002.  
  4003.                     // Re-read after stampReadMarker so its write isn't clobbered.
  4004.                     val st2 = _state.value
  4005.                     val next = st2.copy(
  4006.                         nicklists = st2.nicklists + (chanKey to updated),
  4007.                         selectedBuffer = if (shouldSwitch) chanKey else st2.selectedBuffer,
  4008.                         screen = if (shouldSwitch) AppScreen.CHAT else st2.screen
  4009.                     )
  4010.                     _state.value = syncActiveNetworkSummary(next)
  4011.                 }
  4012.             }
  4013.  
  4014.            
  4015.             is IrcEvent.Parted -> {
  4016.                 val st0 = _state.value
  4017.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  4018.  
  4019.                 // If this PART is the result of closing the buffer, don't recreate the buffer on the echo.
  4020.                 val myNickNow = st0.connections[netId]?.myNick ?: st0.myNick
  4021.                 val isMe = casefoldText(netId, ev.nick) == casefoldText(netId, myNickNow)
  4022.                 if (isMe) {
  4023.                     val pendingKey = popPendingCloseForChannel(netId, ev.channel)
  4024.                     // User explicitly left - remove from reconnect rejoin list.
  4025.                     runtimes[netId]?.manuallyJoinedChannels?.remove(ev.channel)
  4026.                     if (pendingKey != null) {
  4027.                         append(
  4028.                             bufKey(netId, "*server*"),
  4029.                             from = null,
  4030.                             text = "*** Left ${ev.channel}",
  4031.                             isLocal = suppressUnread,
  4032.                             timeMs = ev.timeMs,
  4033.                             doNotify = false
  4034.                         )
  4035.                         return
  4036.                     }
  4037.                 }
  4038.  
  4039.                 val chanKey = resolveBufferKey(netId, ev.channel)
  4040.  
  4041.                 if (!st0.settings.hideJoinPartQuit) {
  4042.                     val msg = if (isMe) {
  4043.                         "* You have left channel ${ev.channel}"
  4044.                     } else {
  4045.                         val host = ev.userHost ?: "*!*@*"
  4046.                         "* ${ev.nick} ($host) has left ${ev.channel}" +
  4047.                             (ev.reason?.takeIf { it.isNotBlank() }?.let { " [$it]" } ?: "")
  4048.                     }
  4049.                     append(
  4050.                         chanKey,
  4051.                         from = null,
  4052.                         text = msg,
  4053.                         isLocal = suppressUnread,
  4054.                         timeMs = ev.timeMs,
  4055.                         doNotify = false
  4056.                     )
  4057.                 }
  4058.  
  4059.                 if (shouldAffectLiveState(ev.isHistory, ev.timeMs)) {
  4060.                     // Re-read state after append so we don't overwrite the message we just appended.
  4061.                     val st1 = _state.value
  4062.                     removeNickFromChannel(netId, chanKey, ev.nick)
  4063.                     val updated = rebuildNicklist(netId, chanKey)
  4064.                     // Clear any pending typing indicator for the parted nick.
  4065.                     val bufAfterPart = st1.buffers[chanKey]
  4066.                     val clearedBuf = if (bufAfterPart != null && ev.nick in bufAfterPart.typingNicks)
  4067.                         bufAfterPart.copy(typingNicks = bufAfterPart.typingNicks - ev.nick) else bufAfterPart
  4068.                     val newBufs = if (clearedBuf != null) st1.buffers + (chanKey to clearedBuf) else st1.buffers
  4069.                     _state.value = syncActiveNetworkSummary(st1.copy(nicklists = st1.nicklists + (chanKey to updated), buffers = newBufs))
  4070.                 }
  4071.             }
  4072.  
  4073.             is IrcEvent.Kicked -> {
  4074.                 val st0 = _state.value
  4075.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  4076.  
  4077.                 val chanKey = resolveBufferKey(netId, ev.channel)
  4078.                 ensureBuffer(chanKey)
  4079.  
  4080.                 run {
  4081.                     val myNick = st0.connections[netId]?.myNick ?: st0.myNick
  4082.                     val by = ev.byNick ?: "?"
  4083.                     val reason = ev.reason?.takeIf { it.isNotBlank() }
  4084.                     val msg = if (ev.victim.equals(myNick, ignoreCase = true)) {
  4085.                         "* You were kicked from ${ev.channel} by $by" + (reason?.let { " [$it]" } ?: "")
  4086.                     } else {
  4087.                         "* ${ev.victim} was kicked by $by" + (reason?.let { " [$it]" } ?: "")
  4088.                     }
  4089.  
  4090.                     append(
  4091.                         chanKey,
  4092.                         from = null,
  4093.                         text = msg,
  4094.                         isLocal = suppressUnread,
  4095.                         timeMs = ev.timeMs,
  4096.                         doNotify = false
  4097.                     )
  4098.                 }
  4099.  
  4100. if (shouldAffectLiveState(ev.isHistory, ev.timeMs)) {
  4101.     // Re-read state after append so we don't overwrite the message we just appended.
  4102.     val st1 = _state.value
  4103.  
  4104.     val myNick = st1.connections[netId]?.myNick ?: st1.myNick
  4105.     val victimIsMe = casefoldText(netId, ev.victim) == casefoldText(netId, myNick)
  4106.  
  4107.     removeNickFromChannel(netId, chanKey, ev.victim)
  4108.     if (victimIsMe) {
  4109.         chanNickCase[chanKey] = mutableMapOf()
  4110.         chanNickStatus[chanKey] = mutableMapOf()
  4111.     }
  4112.  
  4113.     val finalList = if (victimIsMe) emptyList() else rebuildNicklist(netId, chanKey)
  4114.     _state.value = syncActiveNetworkSummary(st1.copy(nicklists = st1.nicklists + (chanKey to finalList)))
  4115. }
  4116.             }
  4117.  
  4118.             is IrcEvent.Quit -> {
  4119.                 val st0 = _state.value
  4120.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  4121.                 val reason = ev.reason?.takeIf { it.isNotBlank() }
  4122.  
  4123.                 val affectLive = shouldAffectLiveState(ev.isHistory, ev.timeMs)
  4124.  
  4125.                 val affected = if (!affectLive) {
  4126.                     emptyList()
  4127.                 } else {
  4128.                     st0.nicklists
  4129.                         .filterKeys { it.startsWith("$netId::") }
  4130.                         .filterValues { list -> list.any { parseNickWithPrefixes(netId, it).first.let { b -> casefoldText(netId, b) == casefoldText(netId, ev.nick) } } }
  4131.                         .keys
  4132.                         .toList()
  4133.                 }
  4134.  
  4135.                 val allChannelTargets = st0.buffers.keys
  4136.                     .filter { it.startsWith("$netId::") }
  4137.                     .filter { key ->
  4138.                         val (_, name) = splitKey(key)
  4139.                         isChannelOnNet(netId, name)
  4140.                     }
  4141.  
  4142.                 val targets = when {
  4143.                     affected.isNotEmpty() -> affected
  4144.                     allChannelTargets.isNotEmpty() -> allChannelTargets
  4145.                     else -> listOf(bufKey(netId, "*server*"))
  4146.                 }
  4147.                 if (!st0.settings.hideJoinPartQuit) {
  4148.                     val host = ev.userHost ?: "*!*@*"
  4149.                     val msg = "* ${ev.nick} ($host) has quit" + (reason?.let { " [$it]" } ?: "")
  4150.                     for (k in targets) {
  4151.                         append(
  4152.                             k,
  4153.                             from = null,
  4154.                             text = msg,
  4155.                             isLocal = suppressUnread,
  4156.                             timeMs = ev.timeMs,
  4157.                             doNotify = false
  4158.                         )
  4159.                     }
  4160.                 }
  4161.  
  4162.  
  4163. if (affectLive) {
  4164.     // Remove the quitter from all nicklists we have for this network.
  4165.     // Re-read state after appends so we don't overwrite message updates.
  4166.     val st1 = _state.value
  4167.     val keys = st1.nicklists.keys.filter { it.startsWith("$netId::") }
  4168.     for (k in keys) {
  4169.         removeNickFromChannel(netId, k, ev.nick)
  4170.     }
  4171.     val newNicklists = st1.nicklists.mapValues { (k, list) ->
  4172.         val (kid, _) = splitKey(k)
  4173.         if (kid != netId) list else rebuildNicklist(netId, k)
  4174.     }
  4175.     // Also remove from away state map.
  4176.     nickAwayState[netId]?.remove(casefoldText(netId, ev.nick))
  4177.     // Clear any pending typing indicator for the quitting nick across all buffers on this network.
  4178.     val newBufs = st1.buffers.mapValues { (k, buf) ->
  4179.         if (k.startsWith("$netId::") && ev.nick in buf.typingNicks)
  4180.             buf.copy(typingNicks = buf.typingNicks - ev.nick)
  4181.         else buf
  4182.     }
  4183.     _state.value = syncActiveNetworkSummary(st1.copy(nicklists = newNicklists, buffers = newBufs))
  4184. }
  4185.             }
  4186.  
  4187.             is IrcEvent.TopicReply -> {
  4188.                 val st0 = _state.value
  4189.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  4190.  
  4191.                 val chanKey = resolveBufferKey(netId, ev.channel)
  4192.                 ensureBuffer(chanKey)
  4193.  
  4194.                 if (!ev.isHistory) setTopic(chanKey, ev.topic)
  4195.  
  4196.                 if (!st0.settings.hideTopicOnEntry) {
  4197.                     // mIRC-style join/topic info line
  4198.                     val topicText = ev.topic ?: ""
  4199.                     val msg = "* Topic for ${ev.channel} is: $topicText"
  4200.                     append(
  4201.                         chanKey,
  4202.                         from = null,
  4203.                         text = msg,
  4204.                         isLocal = suppressUnread,
  4205.                         timeMs = ev.timeMs,
  4206.                         doNotify = false
  4207.                     )
  4208.                 }
  4209.             }
  4210.             is IrcEvent.TopicWhoTime -> {
  4211.                 val st0 = _state.value
  4212.                 val suppressUnread = ev.isHistory && !st0.settings.ircHistoryCountsAsUnread
  4213.  
  4214.                 val chanKey = resolveBufferKey(netId, ev.channel)
  4215.                 ensureBuffer(chanKey)
  4216.  
  4217.                 if (!st0.settings.hideTopicOnEntry) {
  4218.                     val whenStr = ev.setAtMs?.let {
  4219.                         try {
  4220.                             java.text.SimpleDateFormat("EEE MMM dd HH:mm:ss yyyy", java.util.Locale.US).format(java.util.Date(it))
  4221.                         } catch (_: Throwable) {
  4222.                             java.util.Date(it).toString()
  4223.                         }
  4224.                     } ?: "unknown time"
  4225.  
  4226.                     val msg = "* Topic for ${ev.channel} set by ${ev.setter} at $whenStr"
  4227.                     append(
  4228.                         chanKey,
  4229.                         from = null,
  4230.                         text = msg,
  4231.                         isLocal = suppressUnread,
  4232.                         timeMs = ev.timeMs,
  4233.                         doNotify = false
  4234.                     )
  4235.                 }
  4236.             }
  4237.             is IrcEvent.Topic -> {
  4238.                 val chanKey = resolveBufferKey(netId, ev.channel)
  4239.                 ensureBuffer(chanKey)
  4240.                 // Always update the topic bar — isHistory only gates the chat line below.
  4241.                 // A live TOPIC command whose server-time tag is >15 s in the past (clock
  4242.                 // drift, or topic set just before you joined) was being flagged as history
  4243.                 // and setTopic was skipped, leaving the bar showing the old topic.
  4244.                 setTopic(chanKey, ev.topic)
  4245.                 if (!ev.isHistory) {
  4246.                     // Append a status line so the change is visible in the buffer.
  4247.                     val topicText = ev.topic?.takeIf { it.isNotBlank() } ?: "(topic cleared)"
  4248.                     val line = if (ev.setter != null)
  4249.                         "* ${ev.setter} changed the topic to: $topicText"
  4250.                     else
  4251.                         "* Topic changed to: $topicText"
  4252.                     append(chanKey, from = null, text = line, doNotify = false, timeMs = ev.timeMs)
  4253.                 }
  4254.             }
  4255.             is IrcEvent.ChannelUserMode -> {
  4256.                 if (!ev.isHistory) {
  4257.                     val chanKey = resolveBufferKey(netId, ev.channel)
  4258.                     updateUserMode(netId, chanKey, ev.nick, ev.prefix, ev.adding)
  4259.                 }
  4260.             }
  4261.             is IrcEvent.ChannelListStart -> {
  4262.                 _channelListBuffer.clear()
  4263.                 _state.value = _state.value.copy(listInProgress = true, channelDirectory = emptyList())
  4264.             }
  4265.             is IrcEvent.ChannelListItem -> {
  4266.                 _channelListBuffer.add(ChannelListEntry(ev.channel, ev.users, ev.topic))
  4267.                 if (_channelListBuffer.size % CHANNEL_LIST_BATCH_SIZE == 0) {
  4268.                     val snapshot = _channelListBuffer.toList()
  4269.                     _state.update { it.copy(channelDirectory = snapshot) }
  4270.                 }
  4271.             }
  4272.             is IrcEvent.ChannelListEnd -> {
  4273.                 val snapshot = _channelListBuffer.toList()
  4274.                 _channelListBuffer.clear()
  4275.                 _state.update { it.copy(channelDirectory = snapshot, listInProgress = false) }
  4276.             }
  4277.  
  4278.             // IRCv3 CHGHOST: update user@host for nick in all shared channel nicklists.
  4279.             // The nicklist stores raw "prefix+nick" strings, not full masks, so we don't need
  4280.             // to update the display - just surface an info line in channels where the nick is present.
  4281.             is IrcEvent.Chghost -> {
  4282.                 if (ev.isHistory) return
  4283.                 val myNick = _state.value.connections[netId]?.myNick ?: return
  4284.                 val isMe = casefoldText(netId, ev.nick) == casefoldText(netId, myNick)
  4285.                 // Find channels where this nick is present
  4286.                 val affectedChannels = _state.value.nicklists
  4287.                     .filterKeys { it.startsWith("$netId::") }
  4288.                     .filter { (_, list) ->
  4289.                         list.any { parseNickWithPrefixes(netId, it).first.let { b ->
  4290.                             casefoldText(netId, b) == casefoldText(netId, ev.nick) } }
  4291.                     }
  4292.                     .map { it.key }
  4293.                 val line = if (isMe) "* Your host is now ${ev.newUser}@${ev.newHost}"
  4294.                            else "* ${ev.nick} is now ${ev.newUser}@${ev.newHost}"
  4295.                 for (k in affectedChannels) {
  4296.                     append(k, from = null, text = line, timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4297.                 }
  4298.                 if (affectedChannels.isEmpty()) {
  4299.                     append(bufKey(netId, "*server*"), from = null, text = line, timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4300.                 }
  4301.             }
  4302.  
  4303.             // IRCv3 ACCOUNT: services account login/logout notification.
  4304.             is IrcEvent.AccountChanged -> {
  4305.                 if (ev.isHistory) return
  4306.                 val myNick = _state.value.connections[netId]?.myNick ?: return
  4307.                 val isMe = casefoldText(netId, ev.nick) == casefoldText(netId, myNick)
  4308.                 val line = when {
  4309.                     ev.account == "*" -> if (isMe) "* You are no longer logged in" else "* ${ev.nick} logged out"
  4310.                     isMe -> "* You are now logged in as ${ev.account}"
  4311.                     else -> "* ${ev.nick} is now logged in as ${ev.account}"
  4312.                 }
  4313.                 // Surface in channels where this nick is visible, or server buffer
  4314.                 val affected = _state.value.nicklists
  4315.                     .filterKeys { it.startsWith("$netId::") }
  4316.                     .filter { (_, list) ->
  4317.                         list.any { parseNickWithPrefixes(netId, it).first.let { b ->
  4318.                             casefoldText(netId, b) == casefoldText(netId, ev.nick) } }
  4319.                     }
  4320.                     .map { it.key }
  4321.                 val targets = if (affected.isNotEmpty()) affected else listOf(bufKey(netId, "*server*"))
  4322.                 for (k in targets) {
  4323.                     append(k, from = null, text = line, timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4324.                 }
  4325.             }
  4326.  
  4327.             // IRCv3 SETNAME: user changed their realname.
  4328.             is IrcEvent.Setname -> {
  4329.                 if (ev.isHistory) return
  4330.                 val myNick = _state.value.connections[netId]?.myNick ?: return
  4331.                 val isMe = casefoldText(netId, ev.nick) == casefoldText(netId, myNick)
  4332.                 val line = if (isMe) "* Your realname is now \"${ev.newRealname}\""
  4333.                            else "* ${ev.nick} changed realname to \"${ev.newRealname}\""
  4334.                 val affected = _state.value.nicklists
  4335.                     .filterKeys { it.startsWith("$netId::") }
  4336.                     .filter { (_, list) ->
  4337.                         list.any { parseNickWithPrefixes(netId, it).first.let { b ->
  4338.                             casefoldText(netId, b) == casefoldText(netId, ev.nick) } }
  4339.                     }
  4340.                     .map { it.key }
  4341.                 val targets = if (affected.isNotEmpty()) affected else listOf(bufKey(netId, "*server*"))
  4342.                 for (k in targets) {
  4343.                     append(k, from = null, text = line, timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4344.                 }
  4345.             }
  4346.  
  4347.             // Incoming channel invite.
  4348.             is IrcEvent.InviteReceived -> {
  4349.                 val serverKey = bufKey(netId, "*server*")
  4350.                 val line = "* ${ev.from} has invited you to ${ev.channel}"
  4351.                 append(serverKey, from = null, text = line, timeMs = ev.timeMs, doNotify = false, isLocal = false, isHighlight = true)
  4352.                 // Also surface in the channel buffer if it already exists (e.g. we were kicked)
  4353.                 val chanKey = resolveBufferKey(netId, ev.channel)
  4354.                 if (_state.value.buffers.containsKey(chanKey)) {
  4355.                     append(chanKey, from = null, text = "* ${ev.from} invited you here", timeMs = ev.timeMs, doNotify = false, isLocal = false)
  4356.                 }
  4357.             }
  4358.  
  4359.             // Server-sent ERROR (fatal). IrcCore already emits Disconnected afterwards.
  4360.             is IrcEvent.ServerError -> {
  4361.                 val serverKey = bufKey(netId, "*server*")
  4362.                 append(serverKey, from = null, text = "*** Server error: ${ev.message}", doNotify = false, isLocal = false)
  4363.             }
  4364.  
  4365.             // AWAY status change for another user (away-notify CAP).
  4366.             // Track away state per-nick so the nicklist can reflect it.
  4367.             is IrcEvent.AwayChanged -> {
  4368.                 val awayMap = nickAwayState.getOrPut(netId) { mutableMapOf() }
  4369.                 // On large servers with away-notify, every away transition adds an entry.
  4370.                 // Nicks are evicted on QUIT but not on PART — cap to prevent unbounded growth.
  4371.                 if (awayMap.size >= 2000) awayMap.clear()
  4372.                 val fold = casefoldText(netId, ev.nick)
  4373.                 val wasAway = awayMap.containsKey(fold)
  4374.                 if (ev.awayMessage != null) {
  4375.                     // Nick set or changed away message.
  4376.                     awayMap[fold] = ev.awayMessage
  4377.                     if (!wasAway) {
  4378.                         // Only print "went away" on transition (not on away-message updates).
  4379.                         val msg = if (ev.awayMessage.isBlank()) "* ${ev.nick} is now away"
  4380.                                   else "* ${ev.nick} is now away (${ev.awayMessage})"
  4381.                         val affected = _state.value.nicklists
  4382.                             .filterKeys { it.startsWith("$netId::") }
  4383.                             .filter { (_, list) ->
  4384.                                 list.any { parseNickWithPrefixes(netId, it).first
  4385.                                     .let { b -> casefoldText(netId, b) == fold } }
  4386.                             }.map { it.key }
  4387.                         for (k in affected) {
  4388.                             append(k, from = null, text = msg, timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4389.                         }
  4390.                     }
  4391.                 } else {
  4392.                     // Nick returned from away.
  4393.                     if (wasAway) {
  4394.                         awayMap.remove(fold)
  4395.                         val msg = "* ${ev.nick} is back"
  4396.                         val affected = _state.value.nicklists
  4397.                             .filterKeys { it.startsWith("$netId::") }
  4398.                             .filter { (_, list) ->
  4399.                                 list.any { parseNickWithPrefixes(netId, it).first
  4400.                                     .let { b -> casefoldText(netId, b) == fold } }
  4401.                             }.map { it.key }
  4402.                         for (k in affected) {
  4403.                             append(k, from = null, text = msg, timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4404.                         }
  4405.                     }
  4406.                 }
  4407.             }
  4408.  
  4409.             // CAP NEW / CAP DEL - already logged by IrcSession via EmitStatus; just re-surface as server text.
  4410.             is IrcEvent.CapNew -> {
  4411.                 val serverKey = bufKey(netId, "*server*")
  4412.                 append(serverKey, from = null, text = "*** Server added capabilities: ${ev.caps.joinToString(" ")}", doNotify = false, isLocal = true)
  4413.             }
  4414.             is IrcEvent.CapDel -> {
  4415.                 val serverKey = bufKey(netId, "*server*")
  4416.                 append(serverKey, from = null, text = "*** Server removed capabilities: ${ev.caps.joinToString(" ")}", doNotify = false, isLocal = true)
  4417.             }
  4418.  
  4419.             // soju BOUNCER NETWORK: track upstream network info.
  4420.             is IrcEvent.BouncerNetwork -> {
  4421.                 // Surface a one-line status in the server buffer so the user can see which
  4422.                 // upstream networks the bouncer reports.  Full per-network buffer trees are
  4423.                 // deferred to a future feature; for now this gives useful diagnostic info.
  4424.                 val serverKey = bufKey(netId, "*server*")
  4425.                 val stateStr = ev.state ?: "unknown"
  4426.                 val nameStr  = ev.name  ?: ev.networkId
  4427.                 val hostStr  = if (ev.host != null) " (${ev.host})" else ""
  4428.                 append(serverKey, from = null, text = "*** Bouncer network: $nameStr$hostStr [$stateStr]", doNotify = false, isLocal = true)
  4429.             }
  4430.  
  4431.             is IrcEvent.MonitorStatus -> {
  4432.                 // MONITOR: a watched nick came online or went offline.
  4433.                 // Show a brief status line in the server buffer (and PM buffer if open).
  4434.                 val statusLine = if (ev.online) "*** ${ev.nick} is online" else "*** ${ev.nick} is offline"
  4435.                 val serverKey = bufKey(netId, "*server*")
  4436.                 append(serverKey, from = null, text = statusLine, doNotify = false, isLocal = true, timeMs = ev.timeMs)
  4437.                 // Also show in the PM buffer for that nick, if it exists.
  4438.                 val pmKey = resolveBufferKey(netId, ev.nick)
  4439.                 if (_state.value.buffers.containsKey(pmKey)) {
  4440.                     append(pmKey, from = null, text = statusLine, doNotify = false, isLocal = true, timeMs = ev.timeMs)
  4441.                 }
  4442.             }
  4443.  
  4444.             is IrcEvent.ReadMarker -> {
  4445.                 // Server confirmed a read marker update. Store it so the UI can show
  4446.                 // unread-message separators when catching up after reconnect.
  4447.                 val targetKey = resolveBufferKey(netId, ev.target)
  4448.                 _state.update { st ->
  4449.                     val buf = st.buffers[targetKey] ?: return@update st
  4450.                     st.copy(buffers = st.buffers + (targetKey to buf.copy(
  4451.                         lastReadTimestamp = ev.timestamp
  4452.                     )))
  4453.                 }
  4454.             }
  4455.  
  4456.             is IrcEvent.TypingStatus -> {
  4457.                 // draft/typing: update per-buffer typing indicator set.
  4458.                 // Silently ignore if user has opted out of receiving typing indicators.
  4459.                 if (!_state.value.settings.receiveTypingIndicator) return
  4460.                 // For channel TAGMSGs, ev.target is the channel name → route there.
  4461.                 // For PM TAGMSGs, ev.target is our own nick; the buffer is keyed by the sender.
  4462.                 val bufferName = if (isChannelOnNet(netId, ev.target)) ev.target else ev.nick
  4463.                 val targetKey = resolveBufferKey(netId, bufferName)
  4464.                 _state.update { st ->
  4465.                     val buf = st.buffers[targetKey] ?: return@update st
  4466.                     val updatedTyping = when (ev.state) {
  4467.                         "active", "paused" -> buf.typingNicks + ev.nick
  4468.                         else /* "done" */  -> buf.typingNicks - ev.nick
  4469.                     }
  4470.                     st.copy(buffers = st.buffers + (targetKey to buf.copy(typingNicks = updatedTyping)))
  4471.                 }
  4472.                 // Manage the auto-expiry timer for this nick (IRCv3 recommends expiring after 30 s
  4473.                 // of no update so stale "is typing..." banners don't persist if "done" is never sent).
  4474.                 val expiryKey = "$targetKey/${ev.nick}"
  4475.                 receivedTypingExpiryJobs[expiryKey]?.cancel()
  4476.                 if (ev.state == "active" || ev.state == "paused") {
  4477.                     receivedTypingExpiryJobs[expiryKey] = viewModelScope.launch {
  4478.                         delay(30_000L)
  4479.                         receivedTypingExpiryJobs.remove(expiryKey)
  4480.                         _state.update { st ->
  4481.                             val buf = st.buffers[targetKey] ?: return@update st
  4482.                             st.copy(buffers = st.buffers + (targetKey to buf.copy(typingNicks = buf.typingNicks - ev.nick)))
  4483.                         }
  4484.                     }
  4485.                 } else {
  4486.                     receivedTypingExpiryJobs.remove(expiryKey)
  4487.                 }
  4488.             }
  4489.  
  4490.             is IrcEvent.WhoxReply -> {
  4491.                 // WHOX 354 reply: update away status from flags field.
  4492.                 val fold = casefoldText(netId, ev.nick)
  4493.  
  4494.                 // Track away state from WHOX flags ('G'=gone/away, 'H'=here).
  4495.                 if (ev.isAway != null) {
  4496.                     val awayMap = nickAwayState.getOrPut(netId) { mutableMapOf() }
  4497.                     if (ev.isAway) {
  4498.                         awayMap.putIfAbsent(fold, "")  // Set away without overwriting a known message.
  4499.                     } else {
  4500.                         awayMap.remove(fold)
  4501.                     }
  4502.                 }
  4503.                 // WhoxReply account field currently informational; full account enrichment can
  4504.                 // be added to the nicklist display in a future UI pass.
  4505.             }
  4506.  
  4507.             // draft/channel-rename: server renamed a channel we're in.
  4508.             // Update all buffer keys and nicklist keys that use the old name.
  4509.             is IrcEvent.ChannelRenamed -> {
  4510.                 val oldKey = resolveBufferKey(netId, ev.oldName)
  4511.                 val newKey = resolveBufferKey(netId, ev.newName)
  4512.                 // Mutate in-memory nick maps BEFORE _state.value assignment (not inside update{} to
  4513.                 // avoid double-execution on CAS retry).
  4514.                 chanNickCase.remove(oldKey)?.let { chanNickCase[newKey] = it }
  4515.                 chanNickStatus.remove(oldKey)?.let { chanNickStatus[newKey] = it }
  4516.                 val st = _state.value
  4517.                 val bufs = st.buffers.toMutableMap()
  4518.                 val oldBuf = bufs.remove(oldKey)
  4519.                 if (oldBuf != null) bufs[newKey] = oldBuf.copy(name = ev.newName)
  4520.                 val nickLists = st.nicklists.toMutableMap()
  4521.                 val oldNicks = nickLists.remove(oldKey)
  4522.                 if (oldNicks != null) nickLists[newKey] = oldNicks
  4523.                 val selectedBuf = if (st.selectedBuffer == oldKey) newKey else st.selectedBuffer
  4524.                 _state.value = syncActiveNetworkSummary(st.copy(
  4525.                     buffers = bufs,
  4526.                     nicklists = nickLists,
  4527.                     selectedBuffer = selectedBuf
  4528.                 ))
  4529.             }
  4530.  
  4531.             // draft/message-reactions: an emoji reaction was added or removed.
  4532.             // Surface as a brief status line in the target buffer.
  4533.             is IrcEvent.MessageReaction -> {
  4534.                 val bufKey = resolveBufferKey(netId, ev.target)
  4535.                 val verb = if (ev.adding) "reacted with" else "removed reaction"
  4536.                 val refStr = ev.msgId?.let { " (ref: $it)" } ?: ""
  4537.                 append(bufKey, from = null,
  4538.                     text = "* ${ev.fromNick} $verb ${ev.reaction}$refStr",
  4539.                     timeMs = ev.timeMs, doNotify = false, isLocal = true)
  4540.             }
  4541.  
  4542.             // ChannelModeChanged: live MODE change string (for future UI display of channel modes).
  4543.             // Currently surfaced as a status line; the modeString in UiBuffer is updated separately
  4544.             // by ChannelModeIs (324) when explicitly requested.
  4545.             is IrcEvent.ChannelModeChanged -> {
  4546.                 // Already handled as a ChannelModeLine via the MODE command handler in IrcCore.
  4547.                 // This event exists for UI components that want a structured mode-change signal.
  4548.                 Unit
  4549.             }
  4550.  
  4551.             is IrcEvent.OpenQueryBuffer -> {
  4552.                 // /query <nick> - open a PM buffer and switch to it.
  4553.                 val key = bufKey(netId, ev.nick)
  4554.                 ensureBuffer(key)
  4555.                 openBuffer(key)
  4556.             }
  4557.         }
  4558.     }
  4559.  
  4560.     private fun setNetConn(netId: String, f: (NetConnState) -> NetConnState) {
  4561.         var shouldRefresh = false
  4562.         _state.update { st: UiState ->
  4563.             val old = st.connections[netId] ?: NetConnState()
  4564.             val newConns = st.connections + (netId to f(old))
  4565.             val updated = syncActiveNetworkSummary(st.copy(connections = newConns))
  4566.             shouldRefresh = updated.settings.showConnectionStatusNotification || updated.settings.keepAliveInBackground
  4567.             updated
  4568.         }
  4569.         if (shouldRefresh) {
  4570.             refreshConnectionNotification()
  4571.         }
  4572.     }
  4573.  
  4574.     // Buffer + message helpers
  4575.  
  4576.     private fun ensureServerBuffer(netId: String) {
  4577.         ensureBuffer(bufKey(netId, "*server*"))
  4578.     }
  4579.  
  4580.     private fun ensureBuffer(key: String) {
  4581.         // Use atomic update to prevent race conditions when multiple events create buffers.
  4582.         _state.update { st0: UiState ->
  4583.             if (!st0.buffers.containsKey(key)) {
  4584.                 st0.copy(buffers = st0.buffers + (key to UiBuffer(key)))
  4585.             } else {
  4586.                 st0
  4587.             }
  4588.         }
  4589.  
  4590.         // Optional scrollback: preload the latest on-disk log tail into the buffer.
  4591.         // This is independent of "logging enabled" (writing). Users often expect scrollback to load
  4592.         // even if they later turn logging off, as long as logs exist.
  4593.         //
  4594.         // We load disk scrollback even when the server has chathistory, because chathistory
  4595.         // typically provides only 20–50 messages while the user's scrollback may be 800+.
  4596.         // Chathistory messages that arrive afterward are deduplicated in append() by msgid.
  4597.         // The merge below uses a ±3 second fuzzy window to handle timestamp skew between
  4598.         // log-file timestamps (second precision) and server-time tags (millisecond precision).
  4599.         val st = _state.value
  4600.         val buf0 = st.buffers[key] ?: return
  4601.         if (!scrollbackRequested.add(key)) return
  4602.  
  4603.         val (netId, bufferName) = splitKey(key)
  4604.         val netName = st.networks.firstOrNull { it.id == netId }?.name ?: "network"
  4605.         val maxLines = st.settings.maxScrollbackLines.coerceIn(100, 5000)
  4606.  
  4607.         val loadStartMs = System.currentTimeMillis()
  4608.         scrollbackLoadStartedAtMs[key] = loadStartMs
  4609.  
  4610.         viewModelScope.launch(Dispatchers.IO) {
  4611.             val lines = logs.readTail(netName, bufferName, maxLines, st.settings.logFolderUri)
  4612.             if (lines.isEmpty()) {
  4613.                 // Allow a later retry if a log is created after the buffer exists.
  4614.                 scrollbackRequested.remove(key)
  4615.  
  4616.                 scrollbackLoadStartedAtMs.remove(key)
  4617.  
  4618.                 // Nick tracking is live state - empty scrollback (logging off, new buffer) must not clear it.
  4619.                 return@launch
  4620.             }
  4621.  
  4622.             val now = System.currentTimeMillis()
  4623.             val start = now - (lines.size.toLong() * 1000L)
  4624.             val loaded = lines.mapIndexedNotNull { idx, line ->
  4625.                 parseLogLineToUiMessage(line, fallbackTimeMs = start + idx * 1000L)
  4626.             }
  4627.             if (loaded.isEmpty()) return@launch
  4628.  
  4629.             withContext(Dispatchers.Main) {
  4630.                 val cur = _state.value
  4631.                 val buf = cur.buffers[key] ?: return@withContext
  4632.  
  4633.                 // Merge scrollback with any live messages that may have arrived since we started loading.
  4634.                 // We keep a start timestamp so we can place an "end of scrollback" marker before any
  4635.                 // post-connect lines, and avoid obvious duplicates.
  4636.                 val startedAt = scrollbackLoadStartedAtMs.remove(key) ?: loadStartMs
  4637.  
  4638.                 val preExisting = buf.messages.filter { it.timeMs < startedAt }
  4639.                 val liveDuringLoad = buf.messages.filter { it.timeMs >= startedAt }
  4640.                 val firstLiveTime = liveDuringLoad.minOfOrNull { it.timeMs } ?: Long.MAX_VALUE
  4641.  
  4642.                 // Build a set of live message signatures for deduplication.
  4643.                 // Primary: use msgid when available (IRCv3 message-ids) — exact, no false positives.
  4644.                 // Fallback: fuzzy match with ±3 second window. Chathistory delivers server-time in
  4645.                 // milliseconds; disk logs store second-precision timestamps. A 1–2 second skew is
  4646.                 // common (log written at :30, server says :31). 3 seconds catches this reliably
  4647.                 // without false-positiving on back-to-back identical messages from the same nick.
  4648.                 val liveMsgIds = liveDuringLoad.mapNotNull { it.msgId }.toHashSet()
  4649.                 data class FuzzySig(val sec: Long, val from: String?, val text: String)
  4650.                 val liveFuzzy = buildSet<FuzzySig> {
  4651.                     for (msg in liveDuringLoad) {
  4652.                         val sec = msg.timeMs / 1000
  4653.                         val from = msg.from?.lowercase()
  4654.                         val text = msg.text.take(100).lowercase()
  4655.                         for (delta in -3L..3L) add(FuzzySig(sec + delta, from, text))
  4656.                     }
  4657.                 }
  4658.  
  4659.                 // Filter loaded messages: must be older than first live, and not a duplicate.
  4660.                 // Also filter out messages that are too close to the load start time (within 2 seconds)
  4661.                 // to avoid showing messages from the current session as "scrollback".
  4662.                 val olderLoaded = loaded.filter { msg ->
  4663.                     val isOlder = msg.timeMs < (firstLiveTime - 500L)
  4664.                     val isTooRecent = msg.timeMs > (startedAt - 2000L)  // Within 2 seconds of buffer creation
  4665.                     // Prefer msgid-based dedup; fall back to fuzzy ±3s window.
  4666.                     val isDupe = if (msg.msgId != null) {
  4667.                         liveMsgIds.contains(msg.msgId)
  4668.                     } else {
  4669.                         liveFuzzy.contains(FuzzySig(
  4670.                             msg.timeMs / 1000,
  4671.                             msg.from?.lowercase(),
  4672.                             msg.text.take(100).lowercase()
  4673.                         ))
  4674.                     }
  4675.                     isOlder && !isDupe && !isTooRecent
  4676.                 }
  4677.  
  4678.                 // Only show scrollback marker if there are actual old messages (not from current session)
  4679.                 // and there's a meaningful time gap between scrollback and live messages.
  4680.                 val showMarker = cur.settings.loggingEnabled &&
  4681.                     olderLoaded.isNotEmpty() &&
  4682.                     liveDuringLoad.isNotEmpty() &&
  4683.                     (firstLiveTime - olderLoaded.maxOf { it.timeMs }) > 5000L  // At least 5 second gap
  4684.  
  4685.                 val withMarker = if (showMarker) {
  4686.                     // Show the NEWEST scrollback message time (when last activity was)
  4687.                     val newestMs = olderLoaded.maxOf { it.timeMs }
  4688.                     val newestStr = runCatching {
  4689.                         DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
  4690.                             .withZone(ZoneId.systemDefault())
  4691.                             .format(Instant.ofEpochMilli(newestMs))
  4692.                     }.getOrElse { java.util.Date(newestMs).toString() }
  4693.  
  4694.                     val markerTimeMs = if (firstLiveTime != Long.MAX_VALUE) {
  4695.                         // Ensure the marker sorts between scrollback and the first live line.
  4696.                         (firstLiveTime - 1L).coerceAtLeast(newestMs + 1L)
  4697.                     } else {
  4698.                         newestMs + 1L
  4699.                     }
  4700.  
  4701.                     val marker = UiMessage(
  4702.                         id = nextUiMsgId.getAndIncrement(),
  4703.                         timeMs = markerTimeMs,
  4704.                         from = null,
  4705.                         text = "── Scrollback from logs • Last message: $newestStr ──",
  4706.                         isAction = false
  4707.                     )
  4708.                     olderLoaded + preExisting + marker + liveDuringLoad
  4709.                 } else {
  4710.                     olderLoaded + preExisting + liveDuringLoad
  4711.                 }
  4712.  
  4713.                 val merged = withMarker.takeLast(maxLines)
  4714.  
  4715.                 // Rebuild seenMsgIds from the retained messages so the O(1) dedup index
  4716.                 // stays consistent with the actual message list after a scrollback load.
  4717.                 val mergedSeenMsgIds: Set<String> = merged.mapNotNullTo(HashSet()) { it.msgId }
  4718.  
  4719.                 // Use atomic update to prevent race conditions
  4720.                 _state.update { currentState: UiState ->
  4721.                     val currentBuf = currentState.buffers[key] ?: return@update currentState
  4722.                     val newBuf = currentBuf.copy(messages = merged, seenMsgIds = mergedSeenMsgIds)
  4723.                     currentState.copy(buffers = currentState.buffers + (key to newBuf))
  4724.                 }
  4725.             }
  4726.         }
  4727.     }
  4728.  
  4729.     private fun removeBuffer(key: String) {
  4730.         // If this is a DCC CHAT buffer, close the underlying session.
  4731.         val (_, name) = splitKey(key)
  4732.         if (isDccChatBufferName(name)) {
  4733.             closeDccChatSession(key)
  4734.         }
  4735.  
  4736.         scrollbackRequested.remove(key)
  4737.         scrollbackLoadStartedAtMs.remove(key)
  4738.  
  4739.         // Use atomic update to prevent race conditions
  4740.         _state.update { st0: UiState ->
  4741.             if (!st0.buffers.containsKey(key)) return@update st0
  4742.  
  4743.             val newBuffers = st0.buffers - key
  4744.             val newNicklists = st0.nicklists - key
  4745.             val newBanlists = st0.banlists - key
  4746.             val newBanLoading = st0.banlistLoading - key
  4747.             val newQuietlists = st0.quietlists - key
  4748.             val newQuietLoading = st0.quietlistLoading - key
  4749.             val newExceptlists = st0.exceptlists - key
  4750.             val newExceptLoading = st0.exceptlistLoading - key
  4751.             val newInvexlists = st0.invexlists - key
  4752.             val newInvexLoading = st0.invexlistLoading - key
  4753.  
  4754.             val newSelected = if (st0.selectedBuffer == key) {
  4755.                 val (netId, _) = splitKey(key)
  4756.                 val serverKey = bufKey(netId, "*server*")
  4757.                 when {
  4758.                     newBuffers.containsKey(serverKey) -> serverKey
  4759.                     newBuffers.isNotEmpty() -> newBuffers.keys.first()
  4760.                     else -> ""
  4761.                 }
  4762.             } else st0.selectedBuffer
  4763.  
  4764.             syncActiveNetworkSummary(
  4765.                 st0.copy(
  4766.                     buffers = newBuffers,
  4767.                     nicklists = newNicklists,
  4768.                     banlists = newBanlists,
  4769.                     banlistLoading = newBanLoading,
  4770.                     quietlists = newQuietlists,
  4771.                     quietlistLoading = newQuietLoading,
  4772.                     exceptlists = newExceptlists,
  4773.                     exceptlistLoading = newExceptLoading,
  4774.                     invexlists = newInvexlists,
  4775.                     invexlistLoading = newInvexLoading,
  4776.                     selectedBuffer = newSelected
  4777.                 )
  4778.             )
  4779.         }
  4780.     }
  4781.  
  4782.     private fun parseLogLineToUiMessage(line: String, fallbackTimeMs: Long): UiMessage? {
  4783.         val trimmed = line.trimEnd()
  4784.         if (trimmed.isBlank()) return null
  4785.  
  4786.         var timeMs = fallbackTimeMs
  4787.         var body = trimmed
  4788.  
  4789.         // New log format: "yyyy-MM-dd HH:mm:ss <message>"
  4790.         val parts = trimmed.split('     ', limit = 2)
  4791.         if (parts.size == 2) {
  4792.             val maybeTs = parts[0]
  4793.             val maybeBody = parts[1]
  4794.             val parsed = runCatching {
  4795.                 LocalDateTime
  4796.                     .parse(maybeTs, logTimeFormatter)
  4797.                     .atZone(ZoneId.systemDefault())
  4798.                     .toInstant()
  4799.                     .toEpochMilli()
  4800.             }.getOrNull()
  4801.             if (parsed != null) {
  4802.                 timeMs = parsed
  4803.                 body = maybeBody
  4804.             }
  4805.         }
  4806.  
  4807.         var from: String? = null
  4808.         var text = body
  4809.         var isAction = false
  4810.  
  4811.         // Common IRC log line styles — tried in priority order:
  4812.         //
  4813.         //   "*nick* action text"      NEW action format (unambiguous — written by this client
  4814.         //                             going forward).  Server-status lines use "* word …"
  4815.         //                             (asterisk-SPACE) and can never produce this pattern.
  4816.         //
  4817.         //   "<nick> hello"            Regular chat message.
  4818.         //
  4819.         //   "* nick action text"      OLD action format written by earlier versions of this
  4820.         //                             client, and by HexChat/irssi/etc.  Treated as an action
  4821.         //                             only when the first word passes IRC nick validation AND
  4822.         //                             is not a known server-status sentinel word — otherwise
  4823.         //                             the line is kept as a plain server message (from = null).
  4824.         //
  4825.         //   Anything else             Server/status line, rendered as plain text (from = null).
  4826.  
  4827.         // IRC nick validation: may start with letter or _\[]{}|`^ and contain only those
  4828.         // characters plus digits and -.  Crucially excludes <, (, #, @, !, digits as first char.
  4829.         fun isValidNickChar(c: Char, first: Boolean): Boolean = when {
  4830.             c.isLetter() -> true
  4831.             c.isDigit() -> !first
  4832.             c == '-' -> !first
  4833.             c in "_\\[]{}|`^" -> true
  4834.             else -> false
  4835.         }
  4836.         fun looksLikeNick(s: String): Boolean =
  4837.             s.isNotEmpty() && s.length <= 32 &&
  4838.             s[0].let { isValidNickChar(it, first = true) } &&
  4839.             s.all { isValidNickChar(it, first = false) }
  4840.  
  4841.         // Exact first words that HexDroid itself writes in server-status lines that start
  4842.         // with "* " — these must never be misidentified as action nicks from old-format logs.
  4843.         // (e.g. "* Now talking on #channel", "* Topic for #channel is: …", "* Mode #ch +n")
  4844.         val serverStatusFirstWords = setOf("Now", "Topic", "Mode")
  4845.  
  4846.         if (body.startsWith("*") && body.length > 2 && body[1] != ' ' && body[1] != '*') {
  4847.             // New format: *nick* text
  4848.             val closeAst = body.indexOf('*', 1)
  4849.             if (closeAst > 1 && closeAst + 2 <= body.length) {
  4850.                 val nick = body.substring(1, closeAst)
  4851.                 if (looksLikeNick(nick)) {
  4852.                     from = nick
  4853.                     text = if (closeAst + 1 < body.length && body[closeAst + 1] == ' ')
  4854.                         body.substring(closeAst + 2)
  4855.                     else
  4856.                         body.substring(closeAst + 1)
  4857.                     isAction = true
  4858.                 }
  4859.             }
  4860.         } else if (body.startsWith("<") && body.contains("> ")) {
  4861.             val end = body.indexOf("> ")
  4862.             if (end > 1) {
  4863.                 from = body.substring(1, end)
  4864.                 text = body.substring(end + 2)
  4865.             }
  4866.         } else if (body.startsWith("* ") && body.length > 2) {
  4867.             // Old action format — guard against server-status lines.
  4868.             val rest = body.substring(2)
  4869.             val sp = rest.indexOf(' ')
  4870.             if (sp > 0) {
  4871.                 val nick = rest.substring(0, sp)
  4872.                 if (looksLikeNick(nick) && nick !in serverStatusFirstWords) {
  4873.                     from = nick
  4874.                     text = rest.substring(sp + 1)
  4875.                     isAction = true
  4876.                 }
  4877.                 // else: server-status line — leave from=null, text=body (full line)
  4878.             }
  4879.         }
  4880.  
  4881.         return UiMessage(
  4882.             id = nextUiMsgId.getAndIncrement(),
  4883.             timeMs = timeMs,
  4884.             from = from,
  4885.             text = text,
  4886.             isAction = isAction,
  4887.         )
  4888.     }
  4889.  
  4890.     private fun setTopic(key: String, topic: String?) {
  4891.         val st = _state.value
  4892.         val buf = st.buffers[key] ?: UiBuffer(key)
  4893.         _state.value = st.copy(buffers = st.buffers + (key to buf.copy(topic = topic)))
  4894.     }
  4895.  
  4896.  
  4897.     private fun appendNamesList(bufferKey: String, channel: String, names: List<String>) {
  4898.         if (names.isEmpty()) {
  4899.             append(bufferKey, from = null, text = "*** NAMES for $channel: (none)", doNotify = false)
  4900.             return
  4901.         }
  4902.  
  4903.         append(bufferKey, from = null, text = "*** NAMES for $channel (${names.size}):", doNotify = false)
  4904.  
  4905.         val maxLen = 380
  4906.         var sb = StringBuilder()
  4907.         for (n in names) {
  4908.             if (sb.isEmpty()) {
  4909.                 sb.append(n)
  4910.             } else if (sb.length + 1 + n.length > maxLen) {
  4911.                 append(bufferKey, from = null, text = "***   ${sb}", doNotify = false)
  4912.                 sb = StringBuilder(n)
  4913.             } else {
  4914.                 sb.append(' ').append(n)
  4915.             }
  4916.         }
  4917.         if (sb.isNotEmpty()) {
  4918.             append(bufferKey, from = null, text = "***   ${sb}", doNotify = false)
  4919.         }
  4920.     }
  4921.  
  4922.     private fun append(
  4923.         bufferKey: String,
  4924.         from: String?,
  4925.         text: String,
  4926.         isAction: Boolean = false,
  4927.         isHighlight: Boolean = false,
  4928.         isPrivate: Boolean = false,
  4929.         isLocal: Boolean = false,
  4930.         isHistory: Boolean = false,
  4931.         timeMs: Long? = null,
  4932.         doNotify: Boolean = true,
  4933.         isMotd: Boolean = false,
  4934.         msgId: String? = null,
  4935.         replyToMsgId: String? = null,
  4936.     ) {
  4937.         val ts = timeMs ?: System.currentTimeMillis()
  4938.         val msg = UiMessage(
  4939.             id = nextUiMsgId.getAndIncrement(),
  4940.             timeMs = ts,
  4941.             from = from,
  4942.             text = text,
  4943.             isAction = isAction,
  4944.             isMotd = isMotd,
  4945.             msgId = msgId,
  4946.             replyToMsgId = replyToMsgId,
  4947.         )
  4948.  
  4949.         // Atomic update, then read the committed state for logging/notifications.
  4950.         var msgWasDuplicate = false
  4951.         _state.update { st: UiState ->
  4952.             val buf = st.buffers[bufferKey] ?: UiBuffer(bufferKey)
  4953.  
  4954.             // Deduplicate by msgId using the O(1) seenMsgIds HashSet
  4955.             if (msgId != null && buf.seenMsgIds.contains(msgId)) {
  4956.                 msgWasDuplicate = true
  4957.                 return@update st
  4958.             }
  4959.  
  4960.             // Fuzzy dedup for history messages whose live counterpart had no msgid tag.
  4961.             // When a server sends CHATHISTORY LATEST after join, it may replay messages
  4962.             // that were already received live — but the live delivery often lacks a msgid
  4963.             // while the history replay includes one. The msgId check above misses this case.
  4964.             // Guard with isHistory so we never fuzzy-match against live messages.
  4965.             if (isHistory && msgId == null && from != null && buf.messages.isNotEmpty()) {
  4966.                 val incomingSec = ts / 1000
  4967.                 val incomingFrom = from.lowercase()
  4968.                 val incomingText = text.take(100).lowercase()
  4969.                 val isDupe = buf.messages.any { existing ->
  4970.                     val deltaSec = kotlin.math.abs(existing.timeMs / 1000 - incomingSec)
  4971.                     deltaSec <= 3 &&
  4972.                         existing.from?.lowercase() == incomingFrom &&
  4973.                         existing.text.take(100).lowercase() == incomingText
  4974.                 }
  4975.                 if (isDupe) {
  4976.                     msgWasDuplicate = true
  4977.                     return@update st
  4978.                 }
  4979.             }
  4980.  
  4981.             val isSelected = (bufferKey == st.selectedBuffer && st.screen == AppScreen.CHAT)
  4982.             val unreadInc = if (!isSelected && !isLocal) 1 else 0
  4983.             val highlightInc = if (!isSelected && isHighlight && !isLocal) 1 else 0
  4984.  
  4985.             val maxLines = st.settings.maxScrollbackLines.coerceIn(100, 5000)
  4986.             val combined = buf.messages + msg
  4987.             val newMessages = if (combined.size > maxLines) combined.takeLast(maxLines) else combined
  4988.  
  4989.             // Rebuild seenMsgIds from retained messages so evicted entries don't accumulate.
  4990.             // When the buffer isn't trimmed (the common case) we just add the new id; only
  4991.             // after a trim do we pay the cost of rebuilding the set from scratch.
  4992.             val newSeenMsgIds: Set<String> = when {
  4993.                 msgId == null && combined.size <= maxLines -> buf.seenMsgIds
  4994.                 msgId != null && combined.size <= maxLines -> buf.seenMsgIds + msgId
  4995.                 else -> newMessages.mapNotNullTo(HashSet()) { it.msgId }
  4996.             }
  4997.  
  4998.             // Advance lastReadTimestamp for every message on the selected buffer so the
  4999.             // unread separator never appears for messages the user is actively watching.
  5000.             val newLastRead = if (isSelected)
  5001.                 java.time.Instant.ofEpochMilli(ts + 1L).toString()
  5002.             else
  5003.                 buf.lastReadTimestamp
  5004.             val newBuf = buf.copy(
  5005.                 messages = newMessages,
  5006.                 seenMsgIds = newSeenMsgIds,
  5007.                 unread = buf.unread + unreadInc,
  5008.                 highlights = buf.highlights + highlightInc,
  5009.                 lastReadTimestamp = newLastRead
  5010.             )
  5011.             st.copy(buffers = st.buffers + (bufferKey to newBuf))
  5012.         }
  5013.         val st = _state.value
  5014.         if (msgWasDuplicate) return
  5015.  
  5016.         // logging
  5017.         if (st.settings.loggingEnabled) {
  5018.             val (netId, bufferName) = splitKey(bufferKey)
  5019.             if (bufferName != "*server*" || st.settings.logServerBuffer) {
  5020.                 val netName = st.networks.firstOrNull { it.id == netId }?.name ?: "network"
  5021.                 val err = logs.append(netName, bufferName, formatLogLine(ts, from, text, isAction), st.settings.logFolderUri)
  5022.                 if (err != null) {
  5023.                     val serverKey = bufKey(netId, "*server*")
  5024.                     append(serverKey, from = null, text = "*** Log write failed for $bufferName: $err", isLocal = true, doNotify = false)
  5025.                 }
  5026.             }
  5027.         }
  5028.  
  5029.         // notifications
  5030.         // Suppress only when the buffer is actively visible to the user — i.e. it's the
  5031.         // selected buffer on the CHAT screen AND the app is in the foreground.
  5032.         // If the app is backgrounded, always notify regardless of which buffer is "selected",
  5033.         // because the user can't see the message.
  5034.         val isActivelyVisible = (bufferKey == st.selectedBuffer
  5035.             && st.screen == AppScreen.CHAT
  5036.             && AppVisibility.isForeground)
  5037.         if (doNotify && !isActivelyVisible && !isLocal && st.settings.notificationsEnabled) {
  5038.             val (netId, bufferName) = splitKey(bufferKey)
  5039.             val cleanText = stripIrcFormatting(text)
  5040.             val preview = when {
  5041.                 from == null -> cleanText
  5042.                 isAction -> "* $from $cleanText"
  5043.                 else -> "<$from> $cleanText"
  5044.             }
  5045.             // notifTitle is what the user sees; bufferForNotif is the key used to
  5046.             // route the tap back to the correct buffer.  Keep them separate so that
  5047.             // the human-readable network name can be shown without being mistaken
  5048.             // for a channel name when the intent is handled.
  5049.             val notifTitle = if (bufferName == "*server*") {
  5050.                 st.networks.firstOrNull { it.id == netId }?.name ?: "Server"
  5051.             } else bufferName
  5052.             val netDisplayName = st.networks.firstOrNull { it.id == netId }?.name ?: ""
  5053.             val bufferForNotif = bufferName  // always the real buffer key segment
  5054.             // Snippet for quote-fallback when server lacks +reply cap.
  5055.             val originalSnippet = stripIrcFormatting(text).take(100)
  5056.             val senderNick = from ?: ""
  5057.             // Build a stable cross-session anchor for notification → scroll.
  5058.             // Prefer the server-assigned IRC msgid (survives process restarts).
  5059.             // Fall back to epoch-seconds|nick|textPrefix which survives chathistory reload.
  5060.             val msgAnchor = when {
  5061.                 msgId != null -> "msgid:$msgId"
  5062.                 else -> "ts:${ts / 1000}|${(from ?: "")}|${stripIrcFormatting(text).take(80)}"
  5063.             }
  5064.             if (isPrivate && st.settings.notifyOnPrivateMessages) {
  5065.                 runCatching { notifier.notifyPm(netId, bufferForNotif, preview, msg.id, notifTitle, from = senderNick, originalText = originalSnippet, msgAnchor = msgAnchor, networkName = netDisplayName) }
  5066.                 if (st.settings.vibrateOnHighlight) {
  5067.                     runCatching { vibrateForHighlight(st.settings.vibrateIntensity) }
  5068.                 }
  5069.             } else if (isHighlight && st.settings.notifyOnHighlights) {
  5070.                 runCatching { notifier.notifyHighlight(netId, bufferForNotif, preview, st.settings.playSoundOnHighlight, msg.id, notifTitle, from = senderNick, originalText = originalSnippet, msgAnchor = msgAnchor, networkName = netDisplayName) }
  5071.                 if (st.settings.vibrateOnHighlight) {
  5072.                     runCatching { vibrateForHighlight(st.settings.vibrateIntensity) }
  5073.                 }
  5074.             }
  5075.         }
  5076.     }
  5077.  
  5078.     /**
  5079.      * Determine whether a message should be highlighted for a specific network.
  5080.      *
  5081.      * Important: nicks can differ per network, so we must NOT use the global UiState.myNick.
  5082.      */
  5083. /**
  5084.  * Highlight rules:
  5085.  * - Private messages always highlight.
  5086.  * - Nick + extra highlight words match as whole-words (prevents "eck" matching "check").
  5087.  * - Uses per-network CASEMAPPING when folding.
  5088.  */
  5089. private fun isHighlight(netId: String, text: String, isPrivate: Boolean): Boolean {
  5090.     if (isPrivate) return true
  5091.     val s = _state.value.settings
  5092.     if (!s.highlightOnNick && s.extraHighlightWords.isEmpty()) return false
  5093.  
  5094.     val plain = stripIrcFormatting(text)
  5095.     val foldedText = casefoldText(netId, plain)
  5096.  
  5097.     fun isWordChar(c: Char): Boolean = c.isLetterOrDigit() || c == '_'
  5098.  
  5099.     fun containsWholeWord(needleFolded: String): Boolean {
  5100.         if (needleFolded.isBlank()) return false
  5101.         var from = 0
  5102.         while (true) {
  5103.             val idx = foldedText.indexOf(needleFolded, startIndex = from)
  5104.             if (idx < 0) return false
  5105.             val beforeIdx = idx - 1
  5106.             val afterIdx = idx + needleFolded.length
  5107.             val beforeOk = beforeIdx < 0 || !isWordChar(foldedText[beforeIdx])
  5108.             val afterOk = afterIdx >= foldedText.length || !isWordChar(foldedText[afterIdx])
  5109.             if (beforeOk && afterOk) return true
  5110.             from = idx + 1
  5111.             if (from >= foldedText.length) return false
  5112.         }
  5113.     }
  5114.  
  5115.     if (s.highlightOnNick) {
  5116.         val nick = _state.value.connections[netId]?.myNick ?: _state.value.myNick
  5117.         if (nick.isNotBlank() && containsWholeWord(casefoldText(netId, nick))) return true
  5118.     }
  5119.  
  5120.     for (w in s.extraHighlightWords) {
  5121.         val ww = w.trim()
  5122.         if (ww.isBlank()) continue
  5123.         if (containsWholeWord(casefoldText(netId, ww))) return true
  5124.     }
  5125.  
  5126.     return false
  5127. }
  5128.  
  5129. // Nicklist helpers (multi-status + CASEMAPPING aware)
  5130.  
  5131.     /**
  5132.      * Stable casefold for tracking in-flight /NAMES requests.
  5133.      *
  5134.      * We intentionally do NOT use casefoldText(netId, ...) here because CASEMAPPING (005) can arrive mid-request.
  5135.      * If folding rules change between the 353 and 366 numerics, the request key won't match, and the nicklist will
  5136.      * never get an initial snapshot (you'll only see users who join after you).
  5137.      */
  5138.     private fun namesKeyFold(channel: String): String {
  5139.         val sb = StringBuilder(channel.length)
  5140.         for (ch0 in channel) {
  5141.             var ch = ch0
  5142.             if (ch in 'A'..'Z') ch = (ch.code + 32).toChar()
  5143.             ch = when (ch) {
  5144.                 '[', '{' -> '{'
  5145.                 ']', '}' -> '}'
  5146.                 '\\', '|' -> '|'
  5147.                 '^', '~' -> '~'
  5148.                 else -> ch
  5149.             }
  5150.             sb.append(ch)
  5151.         }
  5152.         return sb.toString()
  5153.     }
  5154.  
  5155. /**
  5156.  * Casefold [s] using the CASEMAPPING advertised by the given network's ISUPPORT 005.
  5157.  *
  5158.  * Mirrors IrcClient.casefold() exactly so that buffer-key comparisons in the ViewModel
  5159.  * are consistent with the comparisons IrcCore makes when routing incoming messages.
  5160.  *
  5161.  * rfc1459 / strict-rfc1459 — map the four extended ASCII special-char pairs.
  5162.  * ascii                     — ASCII A-Z only.
  5163.  * anything else             — full Unicode lowercase + RFC1459 special-char pairs.
  5164.  *   (Covers "BulgarianCyrillic+EnglishAlphabet" and any other non-standard token.)
  5165.  */
  5166. private fun casefoldText(netId: String, s: String): String {
  5167.     val cm = (runtimes[netId]?.support?.caseMapping ?: "rfc1459").lowercase(Locale.ROOT)
  5168.     val sb = StringBuilder(s.length)
  5169.     for (ch0 in s) {
  5170.         var ch = ch0
  5171.         if (ch in 'A'..'Z') ch = (ch.code + 32).toChar()
  5172.         when (cm) {
  5173.             "rfc1459", "strict-rfc1459" -> {
  5174.                 ch = when (ch) {
  5175.                     '[', '{' -> '{'
  5176.                     ']', '}' -> '}'
  5177.                     '\\', '|' -> '|'
  5178.                     else -> ch
  5179.                 }
  5180.                 if (cm == "rfc1459") {
  5181.                     if (ch == '^' || ch == '~') ch = '~'
  5182.                 }
  5183.             }
  5184.             "ascii" -> { /* ASCII A-Z already handled */ }
  5185.             else -> {
  5186.                 ch = ch.lowercaseChar()
  5187.                 ch = when (ch) {
  5188.                     '[', '{' -> '{'
  5189.                     ']', '}' -> '}'
  5190.                     '\\', '|' -> '|'
  5191.                     '^', '~' -> '~'
  5192.                     else -> ch
  5193.                 }
  5194.             }
  5195.         }
  5196.         sb.append(ch)
  5197.     }
  5198.     return sb.toString()
  5199. }
  5200.  
  5201. private fun prefixModes(netId: String): String = runtimes[netId]?.support?.prefixModes ?: "qaohv"
  5202. private fun prefixSymbols(netId: String): String = runtimes[netId]?.support?.prefixSymbols ?: "~&@%+"
  5203.  
  5204. private fun parseNickWithPrefixes(netId: String, display: String): Pair<String, Set<Char>> {
  5205.     val ps = prefixSymbols(netId)
  5206.     val pm = prefixModes(netId)
  5207.     var i = 0
  5208.     val modes = linkedSetOf<Char>()
  5209.     while (i < display.length && ps.indexOf(display[i]) >= 0) {
  5210.         val idx = ps.indexOf(display[i])
  5211.         if (idx in 0 until pm.length) modes.add(pm[idx])
  5212.         i++
  5213.     }
  5214.     val base = display.substring(i)
  5215.     return base to modes
  5216. }
  5217.  
  5218. private fun modeForPrefixSymbol(netId: String, sym: Char?): Char? {
  5219.     if (sym == null) return null
  5220.     val idx = prefixSymbols(netId).indexOf(sym)
  5221.     if (idx < 0) return null
  5222.     val pm = prefixModes(netId)
  5223.     return pm.getOrNull(idx)
  5224. }
  5225.  
  5226. private fun highestPrefixSymbol(netId: String, modes: Set<Char>): Char? {
  5227.     if (modes.isEmpty()) return null
  5228.     val pm = prefixModes(netId)
  5229.     val ps = prefixSymbols(netId)
  5230.     var bestIdx = Int.MAX_VALUE
  5231.     for (m in modes) {
  5232.         val idx = pm.indexOf(m)
  5233.         if (idx >= 0 && idx < bestIdx) bestIdx = idx
  5234.     }
  5235.     return if (bestIdx != Int.MAX_VALUE) ps.getOrNull(bestIdx) else null
  5236. }
  5237.  
  5238. private fun nickRank(netId: String, display: String): Int {
  5239.     val (_, modes) = parseNickWithPrefixes(netId, display)
  5240.     val sym = highestPrefixSymbol(netId, modes)
  5241.     val idx = if (sym != null) prefixSymbols(netId).indexOf(sym) else -1
  5242.     return if (idx >= 0) idx else prefixSymbols(netId).length
  5243. }
  5244.  
  5245. private fun rebuildNicklist(netId: String, chanKey: String): List<String> {
  5246.     val baseMap = chanNickCase[chanKey].orEmpty()
  5247.     val modeMap = chanNickStatus[chanKey].orEmpty()
  5248.     val ps = prefixSymbols(netId)
  5249.     val prefixChars = ps.toCharArray()
  5250.  
  5251.     val out = baseMap.entries.map { (fold, base) ->
  5252.         val modes = modeMap[fold].orEmpty()
  5253.         val sym = highestPrefixSymbol(netId, modes)
  5254.         (sym?.toString() ?: "") + base
  5255.     }
  5256.  
  5257.     return out.distinct().sortedWith(Comparator { a, b ->
  5258.         val ra = nickRank(netId, a)
  5259.         val rb = nickRank(netId, b)
  5260.         if (ra != rb) ra - rb
  5261.         else {
  5262.             val ba = a.trimStart(*prefixChars)
  5263.             val bb = b.trimStart(*prefixChars)
  5264.             casefoldText(netId, ba).compareTo(casefoldText(netId, bb))
  5265.         }
  5266.     })
  5267. }
  5268.  
  5269. private fun upsertNickInChannel(netId: String, chanKey: String, baseNick: String, modes: Set<Char>? = null) {
  5270.     val fold = casefoldText(netId, baseNick)
  5271.     val baseMap = chanNickCase.getOrPut(chanKey) { mutableMapOf() }
  5272.     baseMap[fold] = baseNick
  5273.     val modeMap = chanNickStatus.getOrPut(chanKey) { mutableMapOf() }
  5274.     if (modes != null) {
  5275.         modeMap[fold] = modes.toMutableSet()
  5276.     } else {
  5277.         modeMap.getOrPut(fold) { mutableSetOf() }
  5278.     }
  5279. }
  5280.  
  5281. private fun removeNickFromChannel(netId: String, chanKey: String, nick: String) {
  5282.     val fold = casefoldText(netId, nick)
  5283.     chanNickCase[chanKey]?.remove(fold)
  5284.     chanNickStatus[chanKey]?.remove(fold)
  5285.     if (chanNickCase[chanKey]?.isEmpty() == true) chanNickCase.remove(chanKey)
  5286.     if (chanNickStatus[chanKey]?.isEmpty() == true) chanNickStatus.remove(chanKey)
  5287. }
  5288.  
  5289. private fun setNicklistState(netId: String, chanKey: String) {
  5290.     val st = _state.value
  5291.     val rebuilt = rebuildNicklist(netId, chanKey)
  5292.     _state.value = syncActiveNetworkSummary(st.copy(nicklists = st.nicklists + (chanKey to rebuilt)))
  5293. }
  5294.  
  5295. private fun applyNamesDelta(netId: String, chanKey: String, names: List<String>) {
  5296.     for (raw in names) {
  5297.         val (base, modes) = parseNickWithPrefixes(netId, raw)
  5298.         if (base.isBlank()) continue
  5299.         upsertNickInChannel(netId, chanKey, baseNick = base, modes = modes)
  5300.     }
  5301.     setNicklistState(netId, chanKey)
  5302. }
  5303.  
  5304. private fun applyNamesSnapshot(netId: String, chanKey: String, names: List<String>) {
  5305.     chanNickCase[chanKey] = mutableMapOf()
  5306.     chanNickStatus[chanKey] = mutableMapOf()
  5307.     applyNamesDelta(netId, chanKey, names)
  5308. }
  5309.  
  5310. private fun updateUserMode(netId: String, chanKey: String, nick: String, prefixSym: Char?, adding: Boolean) {
  5311.     val fold = casefoldText(netId, nick)
  5312.     val baseMap = chanNickCase.getOrPut(chanKey) { mutableMapOf() }
  5313.     val modeMap = chanNickStatus.getOrPut(chanKey) { mutableMapOf() }
  5314.     baseMap.putIfAbsent(fold, nick)
  5315.     val set = modeMap.getOrPut(fold) { mutableSetOf() }
  5316.     val mode = modeForPrefixSymbol(netId, prefixSym) ?: return
  5317.     if (adding) set.add(mode) else set.remove(mode)
  5318.     setNicklistState(netId, chanKey)
  5319. }
  5320.  
  5321. private fun moveNickAcrossChannels(netId: String, oldNick: String, newNick: String) {
  5322.     val oldFold = casefoldText(netId, oldNick)
  5323.     val newFold = casefoldText(netId, newNick)
  5324.     val keys = chanNickCase.keys.filter { it.startsWith("$netId::") }
  5325.     for (k in keys) {
  5326.         val baseMap = chanNickCase[k] ?: continue
  5327.         val base = baseMap.remove(oldFold) ?: continue
  5328.         val modes = chanNickStatus[k]?.remove(oldFold)
  5329.         baseMap[newFold] = newNick
  5330.         if (modes != null) {
  5331.             val mm = chanNickStatus.getOrPut(k) { mutableMapOf() }
  5332.             mm[newFold] = modes
  5333.         }
  5334.     }
  5335. }
  5336.  
  5337.  
  5338.  
  5339.     // Connection notifications
  5340.  
  5341.     private fun updateConnectionNotification(status: String) {
  5342.         refreshConnectionNotification(statusOverride = status)
  5343.     }
  5344.  
  5345.     private fun clearConnectionNotification() {
  5346.         refreshConnectionNotification(statusOverride = null)
  5347.     }
  5348.  
  5349.     private fun refreshConnectionNotification(statusOverride: String? = null) {
  5350.         val st = _state.value
  5351.         if (appExitRequested) {
  5352.             // Don't resurrect the notification/FGS during an explicit user exit.
  5353.             runCatching { appContext.stopService(Intent(appContext, KeepAliveService::class.java)) }
  5354.             runCatching {
  5355.                 val i = Intent(appContext, KeepAliveService::class.java).apply { action = KeepAliveService.ACTION_STOP }
  5356.                 appContext.startService(i)
  5357.             }
  5358.             runCatching { notifier.cancelConnection() }
  5359.             return
  5360.         }
  5361.         if (!st.settings.showConnectionStatusNotification && !st.settings.keepAliveInBackground) {
  5362.             // Ensure we don't leave stale notifications behind.
  5363.             runCatching {
  5364.                 val i = Intent(appContext, KeepAliveService::class.java).apply { action = KeepAliveService.ACTION_STOP }
  5365.                 appContext.startService(i)
  5366.             }
  5367.             notifier.cancelConnection()
  5368.             return
  5369.         }
  5370.  
  5371.         val connectedIds = st.connections.filterValues { it.connected }.keys
  5372.         val connectingIds = st.connections.filterValues { it.connecting }.keys
  5373.  
  5374.         val displayIds: List<String> = when {
  5375.             connectedIds.isNotEmpty() -> connectedIds.toList()
  5376.             connectingIds.isNotEmpty() -> connectingIds.toList()
  5377.             else -> emptyList()
  5378.         }
  5379.  
  5380.         if (displayIds.isEmpty()) {
  5381.             // If keep-alive is enabled and the user still wants networks connected (auto-reconnect),
  5382.             // keep the foreground service alive so the process + reconnect loop can keep running.
  5383.             if (st.settings.keepAliveInBackground && desiredConnected.isNotEmpty()) {
  5384.                 val wanted = desiredConnected.toList()
  5385.                 val namesWanted = wanted.mapNotNull { id -> st.networks.firstOrNull { it.id == id }?.name }.ifEmpty { wanted }
  5386.                 val labelWanted = if (wanted.size > 1) {
  5387.                     "${wanted.size} networks: ${namesWanted.joinToString(", ")}"
  5388.                 } else {
  5389.                     val net = st.networks.firstOrNull { it.id == wanted.first() }
  5390.                     if (net != null) "${net.name} • ${net.host}:${net.port}" else "HexDroid IRC"
  5391.                 }
  5392.                 val netIdForIntent = st.activeNetworkId?.takeIf { wanted.contains(it) } ?: wanted.first()
  5393.                 val statusTxt = if (!hasInternetConnection()) "Waiting for network…" else "Reconnecting…"
  5394.                 val i = Intent(appContext, KeepAliveService::class.java).apply {
  5395.                     action = KeepAliveService.ACTION_UPDATE
  5396.                     putExtra(KeepAliveService.EXTRA_NETWORK_ID, netIdForIntent)
  5397.                     putExtra(KeepAliveService.EXTRA_SERVER_LABEL, labelWanted)
  5398.                     putExtra(KeepAliveService.EXTRA_STATUS, statusTxt)
  5399.                 }
  5400.                 runCatching {
  5401.                     if (KeepAliveService.isRunning) {
  5402.                         appContext.startService(i)
  5403.                     } else if (AppVisibility.isForeground) {
  5404.                         ContextCompat.startForegroundService(appContext, i)
  5405.                     } else {
  5406.                         notifier.showConnection(netIdForIntent, labelWanted, statusTxt)
  5407.                     }
  5408.                 }.onFailure {
  5409.                     notifier.showConnection(netIdForIntent, labelWanted, statusTxt)
  5410.                 }
  5411.                 return
  5412.             }
  5413.  
  5414.             runCatching {
  5415.                 val i = Intent(appContext, KeepAliveService::class.java).apply { action = KeepAliveService.ACTION_STOP }
  5416.                 appContext.startService(i)
  5417.             }
  5418.             notifier.cancelConnection()
  5419.             return
  5420.         }
  5421.  
  5422.         val netIdForIntent = st.activeNetworkId?.takeIf { displayIds.contains(it) } ?: displayIds.first()
  5423.         val names = displayIds.mapNotNull { id -> st.networks.firstOrNull { it.id == id }?.name }.ifEmpty { displayIds }
  5424.  
  5425.         val label = if (displayIds.size > 1) {
  5426.             "${displayIds.size} networks: ${names.joinToString(", ")}" // NotificationHelper prefixes with "Connected to"
  5427.         } else {
  5428.             val net = st.networks.firstOrNull { it.id == displayIds.first() }
  5429.             if (net != null) "${net.name} • ${net.host}:${net.port}" else "HexDroid IRC"
  5430.         }
  5431.  
  5432.         val status = statusOverride ?: when {
  5433.             connectedIds.isNotEmpty() && connectingIds.isNotEmpty() ->
  5434.                 "Connected (${connectedIds.size}), connecting (${connectingIds.size})"
  5435.             connectedIds.isNotEmpty() -> "Connected"
  5436.             else -> "Connecting…"
  5437.         }
  5438.  
  5439.         if (st.settings.keepAliveInBackground) {
  5440.             val i = Intent(appContext, KeepAliveService::class.java).apply {
  5441.                 action = KeepAliveService.ACTION_UPDATE
  5442.                 putExtra(KeepAliveService.EXTRA_NETWORK_ID, netIdForIntent)
  5443.                 putExtra(KeepAliveService.EXTRA_SERVER_LABEL, label)
  5444.                 putExtra(KeepAliveService.EXTRA_STATUS, status)
  5445.             }
  5446.  
  5447.             // Android 12+ can throw ForegroundServiceStartNotAllowedException if we try to start an FGS
  5448.             // while the app is in the background. Also, if the service is already running, we can just
  5449.             // deliver the update intent via startService().
  5450.             runCatching {
  5451.                 if (KeepAliveService.isRunning) {
  5452.                     appContext.startService(i)
  5453.                 } else if (AppVisibility.isForeground) {
  5454.                     ContextCompat.startForegroundService(appContext, i)
  5455.                 } else {
  5456.                     // Background-start of a foreground service may be blocked on Android 12+.
  5457.                     notifier.showConnection(netIdForIntent, label, status)
  5458.                     return
  5459.                 }
  5460.             }.onFailure {
  5461.                 // how a normal notification instead of crashing.
  5462.                 notifier.showConnection(netIdForIntent, label, status)
  5463.             }
  5464.             return
  5465.         }
  5466.  
  5467.         if (st.settings.showConnectionStatusNotification) {
  5468.             notifier.showConnection(netIdForIntent, label, status)
  5469.         } else {
  5470.             notifier.cancelConnection()
  5471.         }
  5472.     }
  5473.  
  5474.     // DCC
  5475.  
  5476.     fun acceptDcc(offer: DccOffer) {
  5477.         val st = _state.value
  5478.  
  5479.         // Android 17+: DCC connections to LAN peers require ACCESS_LOCAL_NETWORK.
  5480.         // Active DCC connects to the sender's IP; passive DCC binds a local port (that's fine,
  5481.         // but the sender then connects back to us over the LAN — still needs the permission).
  5482.         if (!offer.isPassive && isLocalHost(offer.ip) && !hasLocalNetworkPermission()) {
  5483.             append(bufKey(offer.netId.ifBlank { st.activeNetworkId ?: "" }, "*server*"),
  5484.                 from = null,
  5485.                 text = "*** DCC from ${offer.from}: local network permission required (Android 17+). Grant it in Settings → Apps → HexDroid → Permissions.",
  5486.                 isHighlight = true)
  5487.             return
  5488.         }
  5489.  
  5490.         _state.value = st.copy(dccOffers = st.dccOffers.filterNot { it == offer })
  5491.  
  5492.         val incoming = DccTransferState.Incoming(offer)
  5493.         _state.value = _state.value.copy(dccTransfers = _state.value.dccTransfers + incoming)
  5494.  
  5495.         // route the transfer through the network where the offer was received.
  5496.         val netId = offer.netId.takeIf { it.isNotBlank() } ?: _state.value.activeNetworkId ?: return
  5497.         val rt = runtimes[netId] ?: return
  5498.         val c = rt.client
  5499.         val minP = st.settings.dccIncomingPortMin
  5500.         val maxP = st.settings.dccIncomingPortMax
  5501.         val customFolder = st.settings.dccDownloadFolderUri
  5502.  
  5503.         viewModelScope.launch {
  5504.             try {
  5505.                 val savedPath = if (offer.isPassive) {
  5506.                     // Passive/reverse DCC: we open a port and tell the sender to connect.
  5507.                     dcc.receivePassive(
  5508.                         offer = offer,
  5509.                         portMin = minP,
  5510.                         portMax = maxP,
  5511.                         customFolderUri = customFolder,
  5512.                         onListening = { ipAsInt, port, size, token ->
  5513.                             val name = quoteDccFilenameIfNeeded(offer.filename)
  5514.                             val tokenStr = if (offer.turbo) "${token}T" else token.toString()
  5515.                             val payload = "DCC SEND $name $ipAsInt $port $size $tokenStr"
  5516.                             c.ctcp(offer.from, payload)
  5517.                             append(bufKey(netId, "*server*"), from = null, text = "*** Accepted passive DCC offer: ${offer.filename} (listening on $port)", doNotify = false)
  5518.                         }
  5519.                     ) { got, _ ->
  5520.                         updateIncoming(offer) { it.copy(received = got) }
  5521.                     }
  5522.                 } else {
  5523.                     dcc.receive(offer, customFolder) { got, _ ->
  5524.                         updateIncoming(offer) { it.copy(received = got) }
  5525.                     }
  5526.                 }
  5527.                 updateIncoming(offer) { it.copy(done = true, savedPath = savedPath) }
  5528.                 val displayPath = if (savedPath.startsWith("content://")) "Downloads" else savedPath.substringAfterLast('/')
  5529.                 notifier.notifyFileDone(netId, offer.filename, displayPath)
  5530.             } catch (t: Throwable) {
  5531.                 updateIncoming(offer) { it.copy(error = t.message ?: "error") }
  5532.             }
  5533.         }
  5534.     }
  5535.  
  5536.     fun rejectDcc(offer: DccOffer) {
  5537.         _state.value = _state.value.copy(dccOffers = _state.value.dccOffers.filterNot { it == offer })
  5538.         val netId = offer.netId.takeIf { it.isNotBlank() } ?: _state.value.activeNetworkId ?: return
  5539.         append(bufKey(netId, "*server*"), from = null, text = "*** Rejected DCC offer: ${offer.filename}", doNotify = false)
  5540.     }
  5541.  
  5542.     private fun isDccChatBufferName(name: String): Boolean = name.startsWith("DCCCHAT:")
  5543.  
  5544.     private fun dccChatBufferKey(netId: String, peerNick: String): String = bufKey(netId, "DCCCHAT:$peerNick")
  5545.  
  5546.     private fun closeDccChatSession(bufferKey: String, reason: String? = null) {
  5547.         val ses = dccChatSessions.remove(bufferKey) ?: return
  5548.         runCatching { ses.readJob.cancel() }
  5549.         runCatching { ses.socket.close() }
  5550.         val r = reason?.takeIf { it.isNotBlank() }?.let { " ($it)" } ?: ""
  5551.         append(bufferKey, from = null, text = "*** DCC CHAT disconnected$r", doNotify = false)
  5552.     }
  5553.  
  5554.     private fun startDccChatSession(netId: String, peer: String, bufferKey: String, socket: Socket) {
  5555.         // Replace any existing session for this buffer.
  5556.         closeDccChatSession(bufferKey, reason = "replaced")
  5557.  
  5558.         runCatching {
  5559.             socket.tcpNoDelay = true
  5560.             socket.keepAlive = true
  5561.         }
  5562.  
  5563.         val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
  5564.  
  5565.         val job = viewModelScope.launch(Dispatchers.IO) {
  5566.             val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
  5567.             try {
  5568.                 while (true) {
  5569.                     val line = reader.readLine() ?: break
  5570.                     val isAction = line.startsWith("\u0001ACTION ") && line.endsWith("\u0001")
  5571.                     val text = if (isAction) {
  5572.                         line.removePrefix("\u0001ACTION ").removeSuffix("\u0001")
  5573.                     } else line
  5574.                     withContext(Dispatchers.Main) {
  5575.                         append(bufferKey, from = peer, text = text, isAction = isAction)
  5576.                     }
  5577.                 }
  5578.             } catch (t: Throwable) {
  5579.                 withContext(Dispatchers.Main) {
  5580.                     append(bufferKey, from = null, text = "*** DCC CHAT failed: ${(t.message ?: t::class.java.simpleName)}", isHighlight = true)
  5581.                 }
  5582.             } finally {
  5583.                 withContext(Dispatchers.Main) {
  5584.                     dccChatSessions.remove(bufferKey)
  5585.                     runCatching { socket.close() }
  5586.                     append(bufferKey, from = null, text = "*** DCC CHAT closed", doNotify = false)
  5587.                 }
  5588.             }
  5589.         }
  5590.  
  5591.         dccChatSessions[bufferKey] = DccChatSession(netId, peer, bufferKey, socket, writer, job)
  5592.         append(bufferKey, from = null, text = "*** DCC CHAT connected to $peer", doNotify = false)
  5593.     }
  5594.  
  5595.     private fun sendDccChatLine(bufferKey: String, line: String, isAction: Boolean) {
  5596.         val ses = dccChatSessions[bufferKey]
  5597.         if (ses == null) {
  5598.             append(bufferKey, from = null, text = "*** DCC CHAT not connected.", isHighlight = true)
  5599.             return
  5600.         }
  5601.  
  5602.         val payload = if (isAction) "\u0001ACTION $line\u0001" else line
  5603.  
  5604.         // Writing to a socket on the main thread can throw (StrictMode / NetworkOnMainThreadException)
  5605.         // and will also make typing feel laggy if the peer/network is slow. Always write on IO.
  5606.         viewModelScope.launch(Dispatchers.IO) {
  5607.             try {
  5608.                 synchronized(ses.writer) {
  5609.                     ses.writer.write(payload)
  5610.                     ses.writer.write("\r\n")
  5611.                     ses.writer.flush()
  5612.                 }
  5613.             } catch (t: Throwable) {
  5614.                 withContext(Dispatchers.Main.immediate) {
  5615.                     append(
  5616.                         bufferKey,
  5617.                         from = null,
  5618.                         text = "*** DCC CHAT send failed: ${(t.message ?: t::class.java.simpleName)}",
  5619.                         isHighlight = true
  5620.                     )
  5621.                     closeDccChatSession(bufferKey, reason = t.message)
  5622.                 }
  5623.                 return@launch
  5624.             }
  5625.  
  5626.             withContext(Dispatchers.Main.immediate) {
  5627.                 val myNick = _state.value.connections[ses.netId]?.myNick ?: _state.value.myNick
  5628.                 append(bufferKey, from = myNick, text = line, isAction = isAction)
  5629.             }
  5630.         }
  5631.     }
  5632.  
  5633.     fun acceptDccChat(offer: DccChatOffer) {
  5634.         val st = _state.value
  5635.         _state.value = st.copy(dccChatOffers = st.dccChatOffers.filterNot { it == offer })
  5636.  
  5637.         val netId = offer.netId.takeIf { it.isNotBlank() } ?: st.activeNetworkId ?: return
  5638.         val peer = offer.from
  5639.         val key = dccChatBufferKey(netId, peer)
  5640.         ensureBuffer(key)
  5641.         _state.value = _state.value.copy(selectedBuffer = key)
  5642.  
  5643.         // Android 17+: connecting to a LAN peer requires ACCESS_LOCAL_NETWORK.
  5644.         if (isLocalHost(offer.ip) && !hasLocalNetworkPermission()) {
  5645.             append(key, from = null,
  5646.                 text = "*** DCC CHAT from $peer: local network permission required (Android 17+). Grant it in Settings → Apps → HexDroid → Permissions.",
  5647.                 isHighlight = true)
  5648.             return
  5649.         }
  5650.  
  5651.         viewModelScope.launch {
  5652.             try {
  5653.                 append(key, from = null, text = "*** Connecting DCC CHAT to ${offer.from} (${offer.ip}:${offer.port})…", doNotify = false)
  5654.                 val socket = dcc.connectChat(offer)
  5655.                 startDccChatSession(netId, peer, key, socket)
  5656.             } catch (t: Throwable) {
  5657.                 append(key, from = null, text = "*** DCC CHAT connect failed: ${(t.message ?: t::class.java.simpleName)}", isHighlight = true)
  5658.             }
  5659.         }
  5660.     }
  5661.  
  5662.     fun rejectDccChat(offer: DccChatOffer) {
  5663.         _state.value = _state.value.copy(dccChatOffers = _state.value.dccChatOffers.filterNot { it == offer })
  5664.         val netId = offer.netId.takeIf { it.isNotBlank() } ?: _state.value.activeNetworkId ?: return
  5665.         append(bufKey(netId, "*server*"), from = null, text = "*** Rejected DCC CHAT offer from ${offer.from}", doNotify = false)
  5666.     }
  5667.  
  5668.     fun startDccChat(targetNick: String) = startDccChatFlow(targetNick)
  5669.  
  5670.     fun startDccChatFlow(targetNick: String) {
  5671.         val st = _state.value
  5672.         val netId = st.activeNetworkId ?: return
  5673.         val rt = runtimes[netId] ?: return
  5674.         val c = rt.client
  5675.         val peer = targetNick.trim().trimStart('~', '&', '@', '%', '+')
  5676.         if (peer.isBlank()) return
  5677.  
  5678.         if (!st.settings.dccEnabled) {
  5679.             append(bufKey(netId, "*server*"), from = "DCC", text = "DCC is disabled in settings.", isHighlight = true)
  5680.             return
  5681.         }
  5682.  
  5683.         val key = dccChatBufferKey(netId, peer)
  5684.         ensureBuffer(key)
  5685.         _state.value = _state.value.copy(selectedBuffer = key)
  5686.  
  5687.         val minP = st.settings.dccIncomingPortMin
  5688.         val maxP = st.settings.dccIncomingPortMax
  5689.  
  5690.         viewModelScope.launch {
  5691.             try {
  5692.                 append(key, from = null, text = "*** Offering DCC CHAT to $peer…", doNotify = false)
  5693.                 val socket = dcc.startChat(
  5694.                     portMin = minP,
  5695.                     portMax = maxP,
  5696.                     onClient = { ipAsInt, port ->
  5697.                         val payload = "DCC CHAT chat $ipAsInt $port"
  5698.                         c.ctcp(peer, payload)
  5699.                         append(bufKey(netId, "*server*"), from = null, text = "*** Sent DCC CHAT offer to $peer (port $port)", doNotify = false)
  5700.                     }
  5701.                 )
  5702.                 startDccChatSession(netId, peer, key, socket)
  5703.             } catch (t: Throwable) {
  5704.                 append(key, from = null, text = "*** DCC CHAT offer failed: ${(t.message ?: t::class.java.simpleName)}", isHighlight = true)
  5705.             }
  5706.         }
  5707.     }
  5708.  
  5709.     private fun updateIncoming(offer: DccOffer, f: (DccTransferState.Incoming) -> DccTransferState.Incoming) {
  5710.         val st = _state.value
  5711.         val updated = st.dccTransfers.map {
  5712.             if (it is DccTransferState.Incoming && it.offer == offer) f(it) else it
  5713.         }
  5714.         _state.value = st.copy(dccTransfers = updated)
  5715.     }
  5716.  
  5717.     private fun quoteDccFilenameIfNeeded(nameRaw: String): String {
  5718.         val name = nameRaw.replace('"', '_').trim()
  5719.         return if (name.any { it.isWhitespace() }) "\"$name\"" else name
  5720.     }
  5721.  
  5722.     fun sendDccFileFlow(uri: android.net.Uri, targetNick: String) {
  5723.         val netId = _state.value.activeNetworkId ?: return
  5724.         val rt = runtimes[netId] ?: return
  5725.         val c = rt.client
  5726.         if (targetNick.isBlank()) return
  5727.         val target = targetNick.trimStart('~', '&', '@', '%', '+')
  5728.  
  5729.         if (!_state.value.settings.dccEnabled) {
  5730.             append(bufKey(netId, "*server*"), from = "DCC", text = "DCC is disabled in settings.", isHighlight = true)
  5731.             return
  5732.         }
  5733.  
  5734.         val job = viewModelScope.launch {
  5735.             var offerNameForState: String? = null
  5736.             // Buffer to show DCC status messages in - the target's query buffer if open,
  5737.             // otherwise the server buffer.
  5738.             val statusKey = run {
  5739.                 val targetKey = resolveBufferKey(netId, target)
  5740.                 if (_state.value.buffers.containsKey(targetKey)) targetKey
  5741.                 else bufKey(netId, "*server*")
  5742.             }
  5743.             try {
  5744.                 val prepared = prepareDccSendFile(uri)
  5745.                 val file = prepared.file
  5746.                 val offerName = prepared.offerName
  5747.                 offerNameForState = offerName
  5748.                 val jobKey = "$target/$offerName"
  5749.                 // coroutineContext[Job] is always non-null inside a launch block.
  5750.                 outgoingSendJobs[jobKey] = checkNotNull(coroutineContext[kotlinx.coroutines.Job]) { "No Job in coroutine context" }
  5751.                 val st = _state.value
  5752.                 val minP = st.settings.dccIncomingPortMin
  5753.                 val maxP = st.settings.dccIncomingPortMax
  5754.                 val mode = st.settings.dccSendMode
  5755.  
  5756.                 val offerNamePayload = quoteDccFilenameIfNeeded(offerName)
  5757.                 val fileSize = runCatching { file.length() }.getOrDefault(0L)
  5758.  
  5759.                 val outgoing = DccTransferState.Outgoing(target = target, filename = offerName, fileSize = fileSize)
  5760.                 _state.value = st.copy(dccTransfers = st.dccTransfers + outgoing)
  5761.  
  5762.                 fun updateOutgoing(sent: Long) {
  5763.                     val st2 = _state.value
  5764.                     _state.value = st2.copy(dccTransfers = st2.dccTransfers.map {
  5765.                         if (it is DccTransferState.Outgoing && it.target == target && it.filename == offerName) it.copy(bytesSent = sent) else it
  5766.                     })
  5767.                 }
  5768.  
  5769.                 suspend fun doActiveSend() {
  5770.                     val secure = st.settings.dccSecure
  5771.                     val verb = if (secure) "SSEND" else "SEND"
  5772.                     dcc.sendFile(
  5773.                         file = file,
  5774.                         portMin = minP,
  5775.                         portMax = maxP,
  5776.                         secure = secure,
  5777.                         onClient = { ipAsInt, port, size ->
  5778.                             val payload = "DCC $verb $offerNamePayload $ipAsInt $port $size"
  5779.                             c.ctcp(target, payload)
  5780.                             val secureLabel = if (secure) " (SDCC/TLS)" else ""
  5781.                             append(statusKey, from = null, text = "*** Offering $offerName to $target via DCC$secureLabel (active, port $port)…", doNotify = false)
  5782.                         },
  5783.                         onProgress = { sent, _ -> updateOutgoing(sent) }
  5784.                     )
  5785.                 }
  5786.  
  5787.                 suspend fun doPassiveSend(timeoutMs: Long = 120_000L) {
  5788.                     val secure = st.settings.dccSecure
  5789.                     val verb = if (secure) "SSEND" else "SEND"
  5790.                     val token = Random.nextLong(1L, 0x7FFFFFFFL)
  5791.                     val def = CompletableDeferred<DccOffer>()
  5792.                     pendingPassiveDccSends[token] = PendingPassiveDccSend(target, offerName.substringAfterLast('/').substringAfterLast('\\'), fileSize, def)
  5793.                     try {
  5794.                         val ipInt = dcc.localIpv4AsInt()
  5795.                         val payload = "DCC $verb $offerNamePayload $ipInt 0 $fileSize $token"
  5796.                         c.ctcp(target, payload)
  5797.                         val secureLabel = if (secure) " (SDCC/TLS)" else ""
  5798.                         append(statusKey, from = null, text = "*** Offering $offerName to $target via DCC$secureLabel (passive)…", doNotify = false)
  5799.  
  5800.                         val reply = withTimeout(timeoutMs) { def.await() }
  5801.                         if (reply.port <= 0) throw IOException("Invalid passive DCC reply")
  5802.                         append(statusKey, from = null, text = "*** $target accepted; connecting…", doNotify = false)
  5803.  
  5804.                         dcc.sendFileConnect(
  5805.                             file = file,
  5806.                             host = reply.ip,
  5807.                             port = reply.port,
  5808.                             secure = secure,
  5809.                             onProgress = { sent, _ -> updateOutgoing(sent) }
  5810.                         )
  5811.                     } finally {
  5812.                         pendingPassiveDccSends.remove(token)
  5813.                     }
  5814.                 }
  5815.  
  5816.                 when (mode) {
  5817.                     DccSendMode.ACTIVE -> doActiveSend()
  5818.                     DccSendMode.PASSIVE -> doPassiveSend()
  5819.                     DccSendMode.AUTO -> {
  5820.                         // AUTO tries passive first. If the peer doesn't respond within the
  5821.                         // timeout we give up rather than sending a second unsolicited CTCP -
  5822.                         // firing two DCC SEND offers for the same file confuses clients and
  5823.                         // can result in duplicate transfers. The user can retry manually.
  5824.                         try {
  5825.                             doPassiveSend()
  5826.                         } catch (t: TimeoutCancellationException) {
  5827.                             // Re-throw as a plain IOException so the outer catch marks the
  5828.                             // transfer as an error rather than falling through to the success path.
  5829.                             throw IOException("No response from $target — DCC timed out")
  5830.                         }
  5831.                     }
  5832.                 }
  5833.  
  5834.                 val st3 = _state.value
  5835.                 _state.value = st3.copy(dccTransfers = st3.dccTransfers.map {
  5836.                     if (it is DccTransferState.Outgoing && it.target == target && it.filename == offerName) it.copy(done = true) else it
  5837.                 })
  5838.                 outgoingSendJobs.remove(jobKey)
  5839.                 append(statusKey, from = null, text = "*** DCC send complete: $offerName → $target", doNotify = false)
  5840.  
  5841.             } catch (t: Throwable) {
  5842.                 val cancelled = t is kotlinx.coroutines.CancellationException
  5843.                 val msg = if (cancelled) "Cancelled" else (t.message ?: t::class.java.simpleName).trim()
  5844.                 val stErr = _state.value
  5845.                 offerNameForState?.let { fn ->
  5846.                     outgoingSendJobs.remove("$target/$fn")
  5847.                     _state.value = stErr.copy(dccTransfers = stErr.dccTransfers.map {
  5848.                         if (it is DccTransferState.Outgoing && it.target == target && it.filename == fn)
  5849.                             it.copy(done = true, error = if (cancelled) null else msg)
  5850.                         else it
  5851.                     })
  5852.                     if (!cancelled) append(statusKey, from = "DCC", text = "*** DCC send failed: $msg", isHighlight = true)
  5853.                     else append(statusKey, from = null, text = "*** DCC send cancelled: $fn", doNotify = false)
  5854.                 } ?: run {
  5855.                     _state.value = stErr.copy(dccTransfers = stErr.dccTransfers + DccTransferState.Outgoing(target = target, filename = "(unknown)", done = true, error = msg))
  5856.                     if (!cancelled) append(statusKey, from = "DCC", text = "*** DCC send failed: $msg", isHighlight = true)
  5857.                 }
  5858.                 if (cancelled) throw t   // re-throw so coroutine completes correctly
  5859.             }
  5860.         }
  5861.  
  5862.     }
  5863.  
  5864.     /**
  5865.      * Cancel an in-progress outgoing DCC send.
  5866.      * [target] and [filename] must match the values in [DccTransferState.Outgoing].
  5867.      */
  5868.     fun cancelOutgoingDcc(target: String, filename: String) {
  5869.         val jobKey = "$target/$filename"
  5870.         outgoingSendJobs[jobKey]?.cancel()
  5871.         outgoingSendJobs.remove(jobKey)
  5872.     }
  5873.  
  5874.    
  5875. private fun queryDisplayName(uri: android.net.Uri): String? {
  5876.     return try {
  5877.         val proj = arrayOf(
  5878.             OpenableColumns.DISPLAY_NAME,
  5879.             android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME,
  5880.             "_display_name",
  5881.             "display_name"
  5882.         )
  5883.         appContext.contentResolver.query(uri, proj, null, null, null)?.use { c ->
  5884.             if (!c.moveToFirst()) return@use null
  5885.             for (col in proj) {
  5886.                 val idx = c.getColumnIndex(col)
  5887.                 if (idx >= 0) {
  5888.                     val v = runCatching { c.getString(idx) }.getOrNull()
  5889.                     if (!v.isNullOrBlank()) return@use v
  5890.                 }
  5891.             }
  5892.             null
  5893.         }
  5894.     } catch (_: Throwable) {
  5895.         null
  5896.     }
  5897. }
  5898.  
  5899. private data class PreparedDccSend(val file: File, val offerName: String)
  5900.  
  5901. private suspend fun prepareDccSendFile(uri: android.net.Uri): PreparedDccSend = withContext(Dispatchers.IO) {
  5902.     // Try hard to preserve a meaningful filename for the DCC offer.
  5903.     val raw = queryDisplayName(uri)
  5904.         ?: runCatching { java.net.URLDecoder.decode(uri.lastPathSegment ?: "", "UTF-8") }.getOrNull()
  5905.         ?: ("dcc_send_" + System.currentTimeMillis())
  5906.  
  5907.     // Document IDs often look like "primary:Download/foo.txt".
  5908.     val cleaned = raw
  5909.         .substringAfterLast('/')
  5910.         .substringAfterLast('\\')
  5911.         .substringAfterLast(':')
  5912.  
  5913.     val offerName = cleaned
  5914.         .replace(Regex("[^A-Za-z0-9._ -]"), "_")
  5915.         .trim()
  5916.         .ifBlank { "dcc_send_" + System.currentTimeMillis() }
  5917.         .replace(' ', '_') // avoid spaces in CTCP DCC payload
  5918.  
  5919.     val out = run {
  5920.         val candidate = File(appContext.cacheDir, offerName)
  5921.         if (!candidate.exists()) candidate else {
  5922.             val dot = offerName.lastIndexOf('.')
  5923.                         val stem = if (dot > 0) offerName.take(dot) else offerName
  5924.             val ext = if (dot > 0) offerName.drop(dot) else ""
  5925.             File(appContext.cacheDir, "${stem}_${System.currentTimeMillis()}$ext")
  5926.         }
  5927.     }
  5928.  
  5929.     val inp = appContext.contentResolver.openInputStream(uri) ?: throw IOException("Unable to open selected file")
  5930.     inp.use { input ->
  5931.         out.outputStream().use { fos -> input.copyTo(fos) }
  5932.     }
  5933.  
  5934.     PreparedDccSend(file = out, offerName = out.name)
  5935. }
  5936.  
  5937.     // Sharing
  5938.  
  5939.     fun shareFile(path: String) {
  5940.         val f = File(path)
  5941.         if (!f.exists()) return
  5942.         val uri = FileProvider.getUriForFile(appContext, appContext.packageName + ".fileprovider", f)
  5943.         val intent = Intent(Intent.ACTION_SEND).apply {
  5944.             type = if (f.isDirectory) "application/octet-stream" else "*/*"
  5945.             putExtra(Intent.EXTRA_STREAM, uri)
  5946.             addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
  5947.         }
  5948.         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
  5949.         appContext.startActivity(Intent.createChooser(intent, "Share").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
  5950.     }
  5951.  
  5952.     // /SYSINFO
  5953.  
  5954.         private var cachedGpu: String? = null
  5955.  
  5956.         private fun readGpuRendererBestEffort(): String {
  5957.                 return try {
  5958.                         val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
  5959.                         if (display == EGL14.EGL_NO_DISPLAY) return "Unknown"
  5960.  
  5961.                         val vers = IntArray(2)
  5962.                         if (!EGL14.eglInitialize(display, vers, 0, vers, 1)) return "Unknown"
  5963.  
  5964.                         // From here on, eglTerminate must be called in the finally block.
  5965.                         var ctx: android.opengl.EGLContext = EGL14.EGL_NO_CONTEXT
  5966.                         var surf: android.opengl.EGLSurface = EGL14.EGL_NO_SURFACE
  5967.                         try {
  5968.                                 val configAttribs = intArrayOf(
  5969.                                         EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
  5970.                                         EGL14.EGL_RED_SIZE, 8,
  5971.                                         EGL14.EGL_GREEN_SIZE, 8,
  5972.                                         EGL14.EGL_BLUE_SIZE, 8,
  5973.                                         EGL14.EGL_ALPHA_SIZE, 8,
  5974.                                         EGL14.EGL_NONE
  5975.                                 )
  5976.                                 val configs = arrayOfNulls<EGLConfig>(1)
  5977.                                 val num = IntArray(1)
  5978.                                 if (!EGL14.eglChooseConfig(display, configAttribs, 0, configs, 0, 1, num, 0)) {
  5979.                                         return "Unknown"
  5980.                                 }
  5981.                                 val config = configs[0] ?: return "Unknown"
  5982.  
  5983.                                 val ctxAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
  5984.                                 ctx = EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, ctxAttribs, 0)
  5985.                                 if (ctx == EGL14.EGL_NO_CONTEXT) return "Unknown"
  5986.  
  5987.                                 val surfAttribs = intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE)
  5988.                                 surf = EGL14.eglCreatePbufferSurface(display, config, surfAttribs, 0)
  5989.                                 if (surf == EGL14.EGL_NO_SURFACE) return "Unknown"
  5990.  
  5991.                                 EGL14.eglMakeCurrent(display, surf, surf, ctx)
  5992.  
  5993.                                 val vendor = GLES20.glGetString(GLES20.GL_VENDOR)?.trim().orEmpty()
  5994.                                 val renderer = GLES20.glGetString(GLES20.GL_RENDERER)?.trim().orEmpty()
  5995.  
  5996.                                 val joined = listOf(vendor, renderer).filter { it.isNotBlank() }.joinToString(" ")
  5997.                                 if (joined.isBlank()) "Unknown" else joined
  5998.                         } finally {
  5999.                                 // Always detach context and release resources even on early returns above.
  6000.                                 EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
  6001.                                 if (surf != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, surf)
  6002.                                 if (ctx != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, ctx)
  6003.                                 EGL14.eglTerminate(display)
  6004.                         }
  6005.                 } catch (_: Throwable) {
  6006.                         "Unknown"
  6007.                 }
  6008.         }
  6009.  
  6010.     private fun buildSysInfoLine(): String {
  6011.         val device = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}".trim()
  6012.         val api = android.os.Build.VERSION.SDK_INT
  6013.         val release = android.os.Build.VERSION.RELEASE ?: "?"
  6014.         val codename = android.os.Build.VERSION.CODENAME ?: "?"
  6015.         val cpuCores = Runtime.getRuntime().availableProcessors()
  6016.         val cpuModel = readCpuModel().ifBlank { "Unknown" }
  6017.  
  6018.         val am = appContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
  6019.         val mi = ActivityManager.MemoryInfo()
  6020.         am.getMemoryInfo(mi)
  6021.         val totalMem = mi.totalMem
  6022.         val availMem = mi.availMem
  6023.         val usedMem = (totalMem - availMem).coerceAtLeast(0L)
  6024.  
  6025.         val stat = StatFs(android.os.Environment.getDataDirectory().absolutePath)
  6026.         val totalStorage = stat.blockCountLong * stat.blockSizeLong
  6027.         val freeStorage = stat.availableBlocksLong * stat.blockSizeLong
  6028.         val usedStorage = (totalStorage - freeStorage).coerceAtLeast(0L)
  6029.  
  6030.         val usedMemPct = if (totalMem > 0) usedMem.toDouble() / totalMem.toDouble() else 0.0
  6031.         val usedStoPct = if (totalStorage > 0) usedStorage.toDouble() / totalStorage.toDouble() else 0.0
  6032.  
  6033.         val uptimeMs = SystemClock.elapsedRealtime()
  6034.         val uptime = fmtUptime(uptimeMs)
  6035.  
  6036.         val gpu = cachedGpu ?: readGpuRendererBestEffort().also { cachedGpu = it }
  6037.  
  6038.         return "Device: $device running Android $release $codename (API $api), CPU: ${cpuCores}-core $cpuModel, " +
  6039.             "Memory: ${fmtBytes(totalMem)} total, ${fmtBytes(usedMem)} (${fmtPct(usedMemPct)}) used, ${fmtBytes(availMem)} (${fmtPct(1.0 - usedMemPct)}) free, " +
  6040.             "Storage: ${fmtBytes(totalStorage)} total, ${fmtBytes(usedStorage)} (${fmtPct(usedStoPct)}) used, ${fmtBytes(freeStorage)} (${fmtPct(1.0 - usedStoPct)}) free, " +
  6041.             "Graphics: $gpu, Uptime: $uptime"
  6042.     }
  6043.  
  6044.     private fun readCpuModel(): String {
  6045.         return runCatching {
  6046.             val txt = File("/proc/cpuinfo").readText()
  6047.             // Try common keys
  6048.             val keys = listOf("Hardware", "Model", "model name", "Processor", "CPU implementer")
  6049.             for (k in keys) {
  6050.                 val m = Regex("^\\s*${Regex.escape(k)}\\s*:\\s*(.+)$", RegexOption.MULTILINE).find(txt)
  6051.                 if (m != null) return m.groupValues[1].trim()
  6052.             }
  6053.             ""
  6054.         }.getOrDefault("")
  6055.     }
  6056.  
  6057.     private fun fmtBytes(b: Long): String {
  6058.         val gb = 1024.0 * 1024.0 * 1024.0
  6059.         val mb = 1024.0 * 1024.0
  6060.         return when {
  6061.             b >= gb -> String.format(Locale.US, "%.1fGB", b / gb)
  6062.             b >= mb -> String.format(Locale.US, "%.0fMB", b / mb)
  6063.             else -> "${b}B"
  6064.         }
  6065.     }
  6066.  
  6067.     private fun fmtPct(v: Double): String =
  6068.         String.format(Locale.US, "%.1f%%", (v * 100.0).coerceIn(0.0, 100.0))
  6069.  
  6070.     private fun fmtUptime(ms: Long): String {
  6071.         val s = ms / 1000
  6072.         val days = s / 86400
  6073.         val h = (s % 86400) / 3600
  6074.         val m = (s % 3600) / 60
  6075.         val sec = s % 60
  6076.         return if (days > 0) "${days}d ${h}h ${m}m ${sec}s" else "${h}h ${m}m ${sec}s"
  6077.     }
  6078.  
  6079.     override fun onCleared() {
  6080.         super.onCleared()
  6081.         val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
  6082.         networkCallback?.let { cb -> runCatching { cm?.unregisterNetworkCallback(cb) } }
  6083.         networkCallback = null
  6084.         // Flush and close all open log file handles so the last few lines written via the
  6085.         // BufferedWriter cache (fix #8) are not lost when the ViewModel is destroyed.
  6086.         logs.closeAll()
  6087.     }
  6088. }

Raw Paste

Comments 0
Login to post a comment.
  • No comments yet. Be the first.
Login to post a comment. Login or Register
We use cookies. To comply with GDPR in the EU and the UK we have to show you these.

We use cookies and similar technologies to keep this website functional (including spam protection via Google reCAPTCHA or Cloudflare Turnstile), and — with your consent — to measure usage and show ads. See Privacy.