components_login_UserSelect.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPromise.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/quickConnect.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  m.log = new log.Logger("UserSelect")
  m.log.verbose("Initializing UserSelect component")

  m.top.isOptionsAvailable = false
  ' Overhang config — declarative field projected onto the overhang by JRScene (registerOverhangData).
  m.top.isLogoVisible = true
  m.top.findNode("selectUserLabel").text = translate(translationKeys.LabelSelectAUser)
  m.top.findNode("manualLoginButton").text = translate(translationKeys.ButtonManualLogin)
  m.buttons = m.top.findNode("buttons")
  m.userRow = m.top.findNode("userRow")

  ' A picked user / the manual-login + quick-connect buttons emit intents the loginRouter
  ' coordinator handles.
  m.userRow.observeField("userSelected", "onUserSelected")
  m.buttons.observeField("buttonSelected", "onButtonSelected")

  ' Quick Connect is supported on every Jellyfin version JellyRock targets
  ' (>= 10.7.0). Endpoint and request-shape differences are handled inside
  ' sdk.quickConnect.* via versionChecker / apiVersion dispatch. The button is
  ' removed only when the server explicitly reports the feature disabled.
  m.quickConnectButton = m.top.findNode("quickConnect")
  m.quickConnectButton.text = translate(translationKeys.ButtonQuickConnect)
  ' Apply prior probe result immediately if already known disabled this session.
  ' Default of true (fail-open) means the button stays unless we have evidence.
  ' Otherwise the probe runs in onScreenShown (render-thread fetchAsync). On 10.7
  ' the /QuickConnect/Enabled endpoint is missing, so the probe fails open (button
  ' stays visible) - the dialog fallback handles the rare admin-disabled case there.
  if m.global.server.isQuickConnectEnabled = false
    removeQuickConnectButton("server reports Quick Connect disabled (cached)")
  end if

  ' Quick Connect poll cadence. Re-armed by hand after each poll settles rather
  ' than repeating, so a slow response can never overlap the next request.
  m.quickConnectTimer = m.top.findNode("quickConnectTimer")
  m.quickConnectTimer.observeField("fire", "pollQuickConnect")

  m.buttons.callFunc("center")
end sub

' Probe the server's /QuickConnect/Enabled endpoint and remove the QC button if
' the server reports it disabled. Render-thread promise (#551 Batch 1, 3b pattern)
' — replaces the former QuickConnectEnabledTask (createObject + observeFieldScoped
' on responseCode). Fail-open: only a definitive `false` removes the button; any
' non-ok HTTP response, transport failure, or timeout leaves it visible.
sub probeQuickConnectAvailability()
  ' Already removed this session (cached-disabled in init) → nothing to probe.
  if not isValid(m.quickConnectButton) then return

  req = GetApi().BuildGetQuickConnectEnabledRequest()
  if not isValid(req)
    m.log.warn("Quick Connect probe skipped - server URL not configured")
    return
  end if
  m.log.info("Probing /QuickConnect/Enabled to gate the Quick Connect button", "url:", req.url)

  promises.chain(fetchAsync(req, "quickConnectEnabled")).then(sub(res as object)
    if not res.ok
      ' Server reachable but returned a non-2xx status (e.g. 10.7 has no endpoint).
      ' Fail-open: leave the button and the cached global field as-is.
      m.log.warn("Quick Connect probe got non-OK response - failing open", "statusCode:", res.statusCode)
      return
    end if

    ' Endpoint returns a plain boolean body. Coerce defensively in case a future
    ' server returns an object envelope or wraps the value as a string.
    enabled = res.json
    enabledType = type(enabled)
    if enabledType <> "Boolean" and enabledType <> "roBoolean"
      m.log.warn("Quick Connect probe got unexpected body type", "type:", enabledType, "raw:", res.text)
      enabled = (LCase(res.text.Trim()) = "true")
    end if

    m.global.server.isQuickConnectEnabled = enabled
    m.log.info("Quick Connect availability probed", "enabled:", enabled, "statusCode:", res.statusCode)
    if enabled = false
      removeQuickConnectButton("server reports Quick Connect disabled")
    end if
  end sub).catch(sub(err as object)
    ' Transport failure / timeout — fail-open, leave the button visible.
    m.log.warn("Quick Connect probe failed - failing open", err.reason)
  end sub)
end sub

' Remove the Quick Connect button and re-center the remaining buttons.
' Safe to call repeatedly - removeChild is a no-op once the node is detached.
sub removeQuickConnectButton(reason as string)
  if not isValid(m.quickConnectButton) then return
  if isValid(m.quickConnectButton.getParent())
    m.buttons.removeChild(m.quickConnectButton)
    m.buttons.callFunc("center")
  end if
  m.quickConnectButton = invalid
  m.log.info("Quick Connect button removed", reason)
end sub

sub onItemContentChanged()
  stopLoadingSpinner()
  m.top.findNode("UserRow").ItemContent = m.top.itemContent
  redraw()
end sub

' A user tile was picked. Resolve its id from the rendered list (UserRow fires the
' username), publish both, and emit the userSelected intent (was CreateUserSelectGroup
' returning the username to LoginFlow's public-user branch).
sub onUserSelected(event as object)
  name = event.getData()
  if not isValidAndNotEmpty(name) then return
  userId = ""
  for each u in m.top.itemContent
    if u.name = name
      userId = u.id
      exit for
    end if
  end for
  m.top.selectedUserName = name
  m.top.selectedUserId = userId
  m.top.getScene().preLoginIntent = "userSelected"
end sub

' Manual-login / Quick Connect buttons → intents the loginRouter coordinator handles.
sub onButtonSelected(event as object)
  buttonGroup = event.getRoSGNode()
  btn = buttonGroup.getChild(event.getData())
  if not isValid(btn) then return
  if btn.id = "manualLoginButton"
    m.top.getScene().preLoginIntent = "manualLogin"
  else if btn.id = "quickConnect"
    ' NOT an intent: Quick Connect's first three steps are network calls with no
    ' navigation between them, so they belong on the render thread where
    ' fetchAsync works. The coordinator is handed the finished session at the
    ' end (quickConnectAuthenticated), which is the only step that needs it.
    startQuickConnect()
  end if
end sub

' ===========================================================================
' Quick Connect
'
' The whole render-thread half of the flow lives here, because all three of its
' requests are `fetchAsync` promises and the promise registry lives on the `m` of
' the component that called fetchAsync (see source/api/apiPromise.bs). Only the
' last step — turning an AuthenticationResult into a signed-in session — needs
' the main thread, and that is handed to the loginRouter coordinator as the
' `quickConnectAuthenticated` intent.
'
'   press  -> startQuickConnect      POST /QuickConnect/Initiate
'   wait   -> pollQuickConnect       GET  /QuickConnect/Connect   (every 3s)
'   approve-> exchangeQuickConnect   POST /Users/AuthenticateWithQuickConnect
'   finish -> askToSaveCredentials   -> intent -> loginRouter.user.Login()
'
' This replaces a QuickConnect Task NODE that was created fresh on every poll —
' roughly one task thread every three seconds for as long as the user took to
' walk to their phone, which is the fan-out shape source/api/CLAUDE.md forbids
' and the &h29 "too many task threads" class from #728. It also ran user.Login()
' on that task thread, which no other login path does.
' ===========================================================================

' Quick Connect pressed. Start a request and open the dialog once we have a code.
sub startQuickConnect()
  req = GetApi().BuildInitiateQuickConnectRequest()
  if not isValid(req)
    ' No server URL: nothing was asked, so nothing can be said about whether the
    ' feature is on. Not-reachable is the honest report.
    m.log.warn("Quick Connect could not start - server URL not configured")
    reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
    return
  end if

  ' A blocking spinner, briefly: the user pressed a button and nothing can happen
  ' until the server issues a code. This used to be a SYNCHRONOUS call on the
  ' main thread (loginRouter.onQuickConnectRequested), which froze the whole
  ' event loop — remote input included — for up to timeouts.HTTP_MS (10s) on an
  ' unreachable server.
  startLoadingSpinner()
  m.log.info("Starting Quick Connect", "method:", req.method)

  promises.chain(fetchAsync(req, "quickConnectInitiate")).then(sub(res as object)
    stopLoadingSpinner()
    if not res.ok
      ' Only a 401 means the feature is off; a 10.11 server that is still
      ' starting answers 503, and telling that user Quick Connect is disabled
      ' would make them stop trying. See quickConnectInitiateFailure.
      m.log.warn("Quick Connect initiate refused", "statusCode:", res.statusCode)
      reportQuickConnectFailure(quickConnectInitiateFailure(res))
      return
    end if

    session = quickConnectSession(res.json)
    if not isValid(session)
      ' The server answered 200 with nothing usable in it. Not "disabled" — it
      ' said the feature is on and then failed to issue a code.
      m.log.warn("Quick Connect initiate returned no usable secret/code")
      reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
      return
    end if

    openQuickConnectDialog(session)
  end sub).catch(sub(err as object)
    stopLoadingSpinner()
    m.log.warn("Quick Connect initiate failed", err.reason)
    reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
  end sub)
end sub

' Show the code and start polling for approval.
sub openQuickConnectDialog(session as object)
  m.quickConnectSecret = session.secret
  m.quickConnectFailures = 0
  m.quickConnectPolls = 0

  m.quickConnectDialog = showQuickConnectDialog(translate(translationKeys.ButtonQuickConnect), translate(translationKeys.MessageQuickConnectEnterCode), session.code, "onQuickConnectDialogResult")
  m.quickConnectTimer.control = "start"
end sub

' Ask the server whether the code has been approved yet. Fires on the timer, so
' the request is never in flight twice.
sub pollQuickConnect()
  if not isValidAndNotEmpty(m.quickConnectSecret) then return

  req = GetApi().BuildConnectQuickConnectRequest(m.quickConnectSecret)
  if not isValid(req)
    endQuickConnect()
    reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
    return
  end if

  ' A fresh requestId per poll. They are never concurrent — the timer is re-armed
  ' only after a poll settles — but reusing one across requests is the pool's
  ' documented foot-gun, and a numbered id says which poll a log line is about.
  m.quickConnectPolls++
  promises.chain(fetchAsync(req, "quickConnectPoll-" + stri(m.quickConnectPolls).trim())).then(sub(res as object)
    handleQuickConnectPoll(quickConnectPollOutcome(res))
  end sub).catch(sub(err as object)
    m.log.warn("Quick Connect poll failed", err.reason)
    handleQuickConnectPoll(QC_POLL_FAILED)
  end sub)
end sub

' Act on one poll outcome. The classification itself is pure and unit-tested —
' see quickConnectPollOutcome in source/utils/quickConnect.bs for the measured
' server behaviour behind it.
sub handleQuickConnectPoll(outcome as string)
  ' Cancelled while this poll was in flight: the dialog is already gone.
  if not isValidAndNotEmpty(m.quickConnectSecret) then return

  if outcome = QC_POLL_APPROVED
    m.log.info("Quick Connect approved on server - exchanging secret for a token")
    exchangeQuickConnect()
    return
  end if

  if outcome = QC_POLL_EXPIRED
    ' The one outcome the old implementation could not see. It arrives as a 404,
    ' which the sync JSON path turned into "not approved yet" - so an expired
    ' code polled forever behind a dialog the user had no reason to distrust.
    m.log.info("Quick Connect code expired")
    endQuickConnect()
    reportQuickConnectFailure(QC_FAIL_SPENT)
    return
  end if

  if outcome = QC_POLL_FAILED
    m.quickConnectFailures++
    if quickConnectShouldGiveUp(m.quickConnectFailures)
      m.log.warn("Quick Connect gave up after consecutive failures", m.quickConnectFailures)
      endQuickConnect()
      reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
      return
    end if
  else
    ' A clean "not yet" clears the tolerance — the cap is for a run of failures,
    ' not for a total across a session that is otherwise healthy.
    m.quickConnectFailures = 0
  end if

  m.quickConnectTimer.control = "start"
end sub

' Approved: trade the secret for an access token.
sub exchangeQuickConnect()
  secret = m.quickConnectSecret
  ' Stop polling and take the dialog down BEFORE the exchange: the code on it is
  ' spent, and abandonDialog delivers no result, which is right here — the user
  ' did not dismiss it, so onQuickConnectDialogResult must not run.
  m.quickConnectTimer.control = "stop"
  abandonQuickConnectDialog()

  req = GetApi().BuildAuthenticateWithQuickConnectRequest(secret)
  m.quickConnectSecret = ""
  if not isValid(req)
    reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
    return
  end if

  startLoadingSpinner()
  promises.chain(fetchAsync(req, "quickConnectAuthenticate")).then(sub(res as object)
    stopLoadingSpinner()
    if not res.ok or not isValid(res.json)
      ' Almost always the secret being spent — it is consumed on first use and
      ' it expires. The exception is a 503, which is the server starting rather
      ' than anything wrong with the code. See quickConnectExchangeFailure.
      m.log.warn("Quick Connect token exchange refused", "statusCode:", res.statusCode)
      reportQuickConnectFailure(quickConnectExchangeFailure(res))
      return
    end if

    ' A 200 that carries no usable session. Reported HERE because this is the last
    ' step still on a screen that can say anything: once the intent drains, the
    ' coordinator has no dialog and no view field to report through, and the user
    ' would answer the save-credentials question and then get nothing at all.
    '
    ' UNAVAILABLE rather than SPENT — the same reading startQuickConnect gives an
    ' initiate that answers 200 with nothing usable in it. The server did not
    ' reject the code, it agreed and then failed to produce a session, so the
    ' honest advice is to try again rather than to go and fetch a new code.
    if not quickConnectHasSession(res.json)
      m.log.warn("Quick Connect token exchange returned no usable session")
      reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
      return
    end if

    m.quickConnectAuthResult = res.json
    askToSaveQuickConnectCredentials()
  end sub).catch(sub(err as object)
    stopLoadingSpinner()
    m.log.warn("Quick Connect token exchange failed", err.reason)
    reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
  end sub)
end sub

' Signed in, but nothing is persisted yet. Ask the same question LoginScene asks
' with its "save credentials" checkbox, in the standard confirm dialog — the app
' is holding a live session either way, so a cancel is a legitimate "no", not a
' failure.
sub askToSaveQuickConnectCredentials()
  m.saveCredentialsDialog = showConfirmDialog(translate(translationKeys.ButtonQuickConnect), translate(translationKeys.MessageQuickConnectAuthenticatedSaveCredentials), "onSaveQuickConnectCredentialsResult")
end sub

sub onSaveQuickConnectCredentialsResult()
  dialog = m.saveCredentialsDialog
  m.saveCredentialsDialog = invalid
  auth = m.quickConnectAuthResult
  m.quickConnectAuthResult = invalid
  if not isValid(auth) then return

  ' Back (cancelled) means the same thing as No: sign in, persist nothing. The
  ' session is already live either way — this question is only about the registry.
  m.top.saveCredentials = isValid(dialog) and isValid(dialog.result) and dialog.result.confirmed = true
  m.top.quickConnectAuth = auth
  m.log.info("Quick Connect complete", "saveCredentials:", m.top.saveCredentials)
  m.top.getScene().preLoginIntent = "quickConnectAuthenticated"
end sub

' The user dismissed the code dialog. The only outcome it can produce on its own.
sub onQuickConnectDialogResult()
  m.log.info("Quick Connect cancelled by the user")
  m.quickConnectDialog = invalid
  endQuickConnect()
end sub

' Stop polling and drop the dialog + session state. Safe to call repeatedly, and
' safe to call with no flow in progress.
'
' `returnFocusToPicker` is false for exactly one caller — onDestroy. See
' abandonQuickConnectDialog for why the two cases differ.
sub endQuickConnect(returnFocusToPicker = true as boolean)
  if isValid(m.quickConnectTimer) then m.quickConnectTimer.control = "stop"
  m.quickConnectSecret = ""
  m.quickConnectFailures = 0
  abandonQuickConnectDialog(returnFocusToPicker)
end sub

' Take the code dialog down WITHOUT delivering a result, and put focus back where
' it came from.
'
' abandonDialog, not cancelOpenDialog: we ARE the owner, and a result delivered
' back into this scope would re-enter the teardown we are in the middle of.
'
' The re-focus is the part that is easy to miss. abandonDialog() removes the node
' and restores nothing — correct for its other callers, which are all onDestroy,
' where the view is going away and there is no focus to hand back. This screen
' STAYS ON THE AIR, and what comes next (the failure alert, or the
' save-credentials confirm) DERIVES its returnFocusTo from whatever holds focus at
' that instant. Measured on device: after an abandon that derivation collapses to
' the scene root, so dismissing the next dialog would hand focus to nothing. See
' the abandonDialog characterization in tests/source/unit/utils/dialogs.spec.bs.
'
' Safe when no dialog is open: focus is already on the button row, so this is a
' no-op on the paths that fail before a dialog ever opens.
'
' `returnFocusToPicker` is FALSE from onDestroy, and that is not a nicety: this
' view is being replaced, so re-focusing our own button row there could take focus
' off the view that replaces us. A destroyed view has nothing to hand focus back
' to, which is the case abandonDialog's no-restore behaviour is built for.
sub abandonQuickConnectDialog(returnFocusToPicker = true as boolean)
  abandonDialog(m.quickConnectDialog)
  m.quickConnectDialog = invalid
  if returnFocusToPicker and isValid(m.buttons) then m.buttons.setFocus(true)
end sub

' Report a Quick Connect failure in the standard alert dialog.
'
' The three reasons get three different messages because only one of them tells
' the user to stop: DISABLED means the server has the feature switched off, while
' UNAVAILABLE means try again shortly and SPENT means get a fresh code. The
' classification is pure and version-matrixed (source/utils/quickConnect.bs);
' this function only maps it to a string.
'
' Presented AFTER endQuickConnect has taken the code dialog down, never over it:
' presentOverlayDialog supersedes an incumbent overlay, so showing the alert first
' would cancel the code dialog and re-enter onQuickConnectDialogResult.
sub reportQuickConnectFailure(reason as string)
  if reason = QC_FAIL_DISABLED
    messageKey = translationKeys.LabelQuickConnectNotAvailable
  else if reason = QC_FAIL_SPENT
    messageKey = translationKeys.ErrorQuickConnectCodeExpired
  else
    messageKey = translationKeys.ErrorQuickConnectUnreachable
  end if
  showAlertDialog(translate(translationKeys.ButtonQuickConnect), translate(messageKey))
end sub

sub redraw()
  userCount = m.top.itemContent.Count()
  topBorder = 360
  leftBorder = 130
  itemWidth = 300
  itemSpacing = 40

  if userCount < 5
    leftBorder = (1920 - ((userCount * itemWidth) + ((userCount - 1) * itemSpacing))) / 2
  end if
  '   break()
  m.top.findNode("UserRow").translation = [leftBorder, topBorder]
end sub

' JRScreen hook called when the screen is displayed by the screen manager
sub onScreenShown()
  m.log.info("UserSelect screen shown")

  ' The loginRouter coordinator builds the public+saved user list (sync GetPublicUsers on the
  ' main thread) and passes it as route context. Render it once on mount.
  if not isValidAndNotEmpty(m.top.itemContent)
    route = m.top.route
    if isValid(route) and isValid(route.context) and isValid(route.context.users)
      m.top.itemContent = route.context.users
    end if
  end if

  ' Load splashscreen (handles race condition)
  loadSplashscreen()

  ' Probe Quick Connect availability so the button can hide on disabled servers.
  probeQuickConnectAvailability()
end sub

' JRScreen hook called when the screen is hidden by the screen manager
sub onScreenHidden()
  m.log.info("UserSelect screen hidden - clearing backdrop")
  ' Clear backdrop using forceBackdrop to ensure it clears even before login
  m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
end sub

' Load splashscreen if enabled on server
' Always fetches fresh branding config from server
sub loadSplashscreen()
  m.log.info("Loading splashscreen - fetching branding config from server")

  ' Clear backdrop while waiting (forceBackdrop ensures it works before login)
  m.global.sceneManager.callFunc("setBackgroundImage", "", false, true)

  ' Check if value is already cached (from previous session)
  serverNode = m.global.server
  if isValid(serverNode.isSplashscreenEnabled)
    ' Already cached, apply immediately for instant display
    m.log.debug("Splashscreen setting already cached, applying immediately")
    applySplashscreen(serverNode.isSplashscreenEnabled)
  else
    m.log.debug("Splashscreen setting not cached")
  end if

  ' Always refresh in case the cached value is stale. Render-thread promise (#551
  ' Batch 1, 3b pattern) — replaces the former BrandingConfigTask (createObject +
  ' observeFieldScoped on responseCode) plus its companion observer on the global
  ' isSplashscreenEnabled field. The task also wrote that global session-cache
  ' field; we keep that write here and apply the result directly in .then().
  req = GetApi().BuildGetBrandingConfigurationRequest()
  promises.chain(fetchAsync(req, "brandingConfig")).then(sub(res as object)
    serverNode = m.global.server
    if res.ok and isValid(res.json) and isValid(res.json.SplashscreenEnabled)
      serverNode.isSplashscreenEnabled = res.json.SplashscreenEnabled
      m.log.info("Branding config cached successfully", "splashscreenEnabled:", res.json.SplashscreenEnabled)
    else
      ' Any non-ok HTTP response (the error contract resolves these) or a missing
      ' SplashscreenEnabled field → default to disabled, matching the old task's
      ' failure path.
      serverNode.isSplashscreenEnabled = false
      m.log.warn("Branding config fetch returned no usable data - defaulting splashscreen to disabled")
    end if
    applySplashscreen(serverNode.isSplashscreenEnabled)
  end sub).catch(sub(err as object)
    ' Transport failure / timeout — default to disabled, same as the old failure path.
    m.log.warn("Branding config fetch failed", err.reason)
    m.global.server.isSplashscreenEnabled = false
    applySplashscreen(false)
  end sub)
end sub

' Apply splash background based on enabled flag
' Uses forceBackdrop parameter to show splashscreen before user login
sub applySplashscreen(serverSplashEnabled as boolean)
  ' Get global splash screen setting (reads from registry or settings.json default)
  ' This works before user login since global settings are device-wide
  globalSetting = getGlobalSplashScreenSetting()

  ' Resolve splash screen setting (JellyRock global override or server setting)
  isSplashEnabled = resolveSplashScreen(globalSetting, serverSplashEnabled)

  m.log.debug("Applying splashscreen", "globalSetting:", globalSetting, "serverSetting:", serverSplashEnabled, "resolvedValue:", isSplashEnabled)

  if isSplashEnabled = true
    ' Build splash URL with correct parameters
    splashUrl = buildURL("/Branding/Splashscreen", { "format": "jpg", "tag": "splash" })

    ' buildURL returns invalid when server URL is not configured
    if not isValid(splashUrl)
      m.log.warn("Cannot load splashscreen - server URL not configured")
      m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
      return
    end if

    m.log.info("Splashscreen enabled - loading", splashUrl)

    ' Set backdrop with animation, forceBackdrop=true bypasses user setting check
    m.global.sceneManager.callFunc("setBackgroundImage", splashUrl, true, true)
  else
    m.log.info("Splashscreen disabled - using default background")
    m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  if key = "back"
    ' Back → change server (coordinator deletes the server and navigates to /server).
    m.top.getScene().preLoginIntent = "userBack"
    return true
  else if key = "up"
    if m.top.focusedChild.isSubType("JRButtonGroup")
      m.top.findNode("UserRow").setFocus(true)
      return true
    end if
  else if key = "down"
    if m.top.focusedChild.isSubType("UserRow")
      m.buttons.setFocus(true)
      return true
    end if
  end if
  return false
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
' Note: onScreenHidden already cleared the backdrop; this handles task/ref cleanup.
sub onDestroy()
  m.log.verbose("onDestroy")

  ' The branding-config + Quick Connect probes are now render-thread promises
  ' (fetchAsync); any in-flight request is cancelled by the abandonApiPromises()
  ' call the auto-abandon BSC plugin injects as the first statement of onDestroy.

  ' Any Quick Connect flow in progress: stop the poll timer and take the code
  ' dialog down. Overlay dialogs are appended to the SCENE, not to this view, so
  ' one left open here would strand a modal over whatever screen replaces us —
  ' with a scoped observer pointing into a torn-down scope.
  '
  ' `false` = do NOT hand focus back to our button row. Every other caller wants
  ' that; this one must not have it, because the view replacing us is the thing
  ' that should end up focused.
  endQuickConnect(false)
  abandonDialog(m.saveCredentialsDialog)
  m.saveCredentialsDialog = invalid
  m.quickConnectAuthResult = invalid

  ' Release the selection + button observers.
  if isValid(m.userRow) then m.userRow.unobserveField("userSelected")
  if isValid(m.buttons) then m.buttons.unobserveField("buttonSelected")
  if isValid(m.quickConnectTimer) then m.quickConnectTimer.unobserveField("fire")

  ' Clear node references
  m.userRow = invalid
  m.quickConnectButton = invalid
  m.quickConnectTimer = invalid
  m.buttons = invalid
end sub