android, desktop, ios: make the invitation profile flow surgical

No behaviour change. The review rounds left the flow carrying their own reasoning:
267 of the 759 added lines were comments, and the fixes had accreted structure that
was no longer needed.

createProfileForInvitation runs on Main via withApi, as the pickers' own handlers
always did, instead of withBGApi with three withContext(Dispatchers.Main) blocks
inside it. Every call it makes suspends into IO - sendCmd hops there itself - so Main
is never blocked, and the whole class of off-main model mutation that two rounds of
review found simply cannot arise. That removes the two coroutine imports as well.

hasModalOpenNotClosing is gone: while the form is open it is necessarily the last
modal on the fullscreen stack, so the entry guard uses isLastModalOpenNotClosing and
one function covers both call sites.

The rest is comments. Kept the ones that record why something non-obvious is the way
it is - the placement, the main-thread choice, the guards - and dropped the narration
of what the earlier attempts got wrong, which belongs in the history and the plan.
One comment described code that is not there at all; it is the commit message's job
to say why the chatId write was removed.

759 -> 667 added lines, 267 -> 189 of them comments.
This commit is contained in:
Narasimha-sc
2026-08-06 17:03:40 +00:00
parent b9eb86541c
commit 937e688c0a
7 changed files with 85 additions and 177 deletions
@@ -47,12 +47,9 @@ struct ContextProfilePickerView: View {
profilePicker()
}
}
// Attached here, not to the row and not to profilePicker(): the row lives in a
// lazy container that may dispose it, and profilePicker() itself is replaced the
// moment listExpanded flips - which changeProfile does while this sheet is still
// dismissing, and which an incoming event can do at any time by setting
// profileChangeProhibited. This Group survives both. Stacking it with the
// IncognitoHelp sheet on body is fine from iOS 14.5; the app targets 15.
// On the Group, not the row or profilePicker(): both are disposed while this is
// presented - the row by its lazy container, the picker when listExpanded flips.
// Stacking with body's IncognitoHelp sheet is fine from iOS 14.5; target is 15.
.sheet(isPresented: $showAddProfile) {
NavigationView {
CreateProfile(onSubmit: { displayName, shortDescr, image in
@@ -104,9 +101,7 @@ struct ContextProfilePickerView: View {
let otherUsers = users
.filter { u in u.userId != selectedUser.userId }
// Descending, as every other profile list sorts: a profile
// that was never activated has active_order 0 and belongs
// at the end, not the front.
// Descending, as every other profile list sorts
.sorted(using: KeyPathComparator<User>(\.activeOrder, order: .reverse))
ForEach(otherUsers) { p in
profilerPickerUserOption(p)
@@ -183,8 +178,7 @@ struct ContextProfilePickerView: View {
listExpanded = false
}
} else if selectedUser != user {
// Only the branch that starts work is guarded - expanding and
// collapsing the list is local and stays available.
// Only the branch that starts work: expand/collapse is local
if busy { return }
changingProfile = true
changeProfile(user)
@@ -268,22 +262,16 @@ struct ContextProfilePickerView: View {
await MainActor.run {
if let updatedUsers = updatedUsers {
chatModel.users = updatedUsers
// This view's own list is otherwise only filled in onAppear, so without
// this the profile just created is missing from the picker if the
// reassignment below fails - and the row count the frame is sized from
// is one short.
// Otherwise only filled in onAppear, so the profile just created is
// missing from the picker, and the row count the frame uses is one short.
users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden }
}
}
if newUser.activeUser {
// The core did not honour keepActiveUser and activated the profile - an older
// remote host ignoring the unknown field. Reassigning would now fail, so
// resync to what the host actually did and report it. The failure is the
// switch, not the creation, so it is not rethrown into the form's "error
// creating profile" handler.
// let, not var: MainActor.run's body is @Sendable, and capturing a mutable
// local in one is diagnosed under strict concurrency. Assigned on both
// branches, so it is definitely initialised.
// An older remote host ignored keepActiveUser and activated it, so the
// reassignment would fail. Resync to what the host did and report it - not
// rethrown, or the form reports it as a failure to create the profile.
// let, not var: MainActor.run's body is @Sendable and cannot capture a mutable local
let switched: Bool
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
@@ -293,10 +281,8 @@ struct ContextProfilePickerView: View {
switched = false
}
await MainActor.run {
// Only when the switch actually happened: the prepared chat then belongs
// to a profile that is no longer active, so it is absent from the reloaded
// list and a pushed chat view renders blank. If the switch failed nothing
// moved and the chat is still fine - closing it would be the regression.
// Only if the switch happened: the chat is then absent from the reloaded
// list and would render blank. If it failed, the chat is still fine.
if switched && chatModel.chatId == chat.id { chatModel.chatId = nil }
}
// The switch replaces the chat list, which removes this view - and the sheet
+11 -24
View File
@@ -428,12 +428,10 @@ private struct ActiveProfilePicker: View {
dismiss()
}
} else {
// Only reachable with no connection - apiSetConnectionIncognito
// throws rather than returning nil. Nothing was changed, so just
// release the status, which otherwise stays .switchingIncognito
// and latches the picker behind a spinner with hit testing off.
// incognitoEnabled is left alone: it is bound to the app-wide
// default, and writing it marks the invitation as used.
// Nothing changed, so just release the status, which otherwise
// latches the picker behind a spinner with hit testing off.
// incognitoEnabled is the app-wide default: writing it here
// would mark the invitation as used.
await MainActor.run { profileSwitchStatus = .idle }
}
} catch {
@@ -488,13 +486,9 @@ private struct ActiveProfilePicker: View {
}
}
} else {
// No connection, or apiChangeConnectionUser returned nothing:
// nothing was moved, so don't switch or dismiss. Reset the
// status, which otherwise latches the picker into its spinner
// with hit testing off, and put selectedProfile back on the
// active user as the catch below does - otherwise the profile
// that was not switched to keeps the checkmark, and tapping it
// does nothing because the view thinks it is already selected.
// Nothing moved, so don't switch or dismiss. Reset the status,
// and the selection as the catch below does - otherwise the
// profile keeps the checkmark and tapping it does nothing.
await MainActor.run {
profileSwitchStatus = .idle
if let currentUser = chatModel.currentUser {
@@ -529,10 +523,8 @@ private struct ActiveProfilePicker: View {
}
// switchingProfileByTimeout only latches half a second after a switch starts, to keep
// the spinner from flickering on fast switches - but input has to be blocked from the
// moment the work begins, or a second tap inside that window starts a competing
// connection change and user switch.
// switchingProfileByTimeout latches half a second late, to avoid spinner flicker, but
// input must be blocked from the start or a second tap starts a competing switch.
private var busy: Bool { creatingProfile || switchingProfileByTimeout || profileSwitchStatus != .idle }
@ViewBuilder private func viewBody() -> some View {
@@ -601,9 +593,6 @@ private struct ActiveProfilePicker: View {
Spacer()
}
}
// profileSwitchStatus, not just switchingProfileByTimeout: that only latches half
// a second later, and creatingProfile is cleared as soon as the switch is handed
// to the selectedProfile handler - leaving the row live in between.
.disabled(busy)
}
@@ -628,10 +617,8 @@ private struct ActiveProfilePicker: View {
profiles = chatModel.users.map { $0.user }
}
if newUser.activeUser {
// Older core ignored keepActiveUser and switched instead - resync rather than
// attempting a connection change that would now fail. The failure is the
// switch, not the creation, so it is not rethrown into the form's "error
// creating profile" handler.
// An older core ignored keepActiveUser and switched instead, so the connection
// change would fail. Not rethrown, or the form reports it as a creation failure.
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
} catch {
@@ -122,12 +122,9 @@ object ChatModel {
val incompleteInitializedDbRemoved = mutableStateOf(false)
// map of connections network statuses, key is agent connection id
val switchingUsersAndHosts = mutableStateOf(false)
/** True from the moment a profile is submitted in the "add profile for this invitation"
* form until the invitation has been moved onto it. Lives on the model rather than in
* the picker that starts it: on Android every ModalManager placement is one stack rendering only
* its top entry, so the one-time link picker - itself a modal - is disposed while the
* form is above it and returns with every remembered flag reset. (The compose picker is
* not a modal and does survive, on both platforms.) */
/** Set while a profile is being created for an invitation and moved onto it. On the
* model because on Android the picker that starts it is itself a modal, and is disposed
* while the create-profile form is above it - a remembered flag would come back false. */
val creatingProfileForInvitation = mutableStateOf(false)
// current chat
@@ -46,11 +46,9 @@ import chat.simplex.common.views.usersettings.DeleteImageButton
import chat.simplex.common.views.usersettings.EditImageButton
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URI
const val MAX_BIO_LENGTH_BYTES = 160
@@ -349,88 +347,59 @@ private fun CreateFirstProfileDesktop(chatModel: ChatModel, close: () -> Unit) {
}
}
// Creates a profile for an invitation and hands it to onCreated, which moves the
// invitation onto it. The profile is created *without* becoming active: the reassignment
// APIs resolve the prepared chat or connection under the active user, so the profile that
// owns the invitation has to stay active until onCreated has run - which is also why
// onCreated is suspending, so the in-flight flag covers the reassignment and not just the
// creation.
/** Creates a profile for an invitation and hands it to [onCreated], which moves the
* invitation onto it. The profile is created *without* becoming active: the reassignment
* APIs resolve the prepared chat or connection under the active user, so the profile that
* owns the invitation has to stay active until [onCreated] has run. [onCreated] suspends
* so the in-flight flag covers the reassignment as well as the creation. */
fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) {
// ModalManager.fullscreen on purpose. center nulls chatId on desktop, closing the very
// chat a prepared invitation is in; end widens the window for good and leaves the
// compose picker live in the pane beside the form; start would work for the one-time
// link picker but disposes it, losing what was typed in its search box. fullscreen is
// an opaque Surface over every pane, so no picker can be operated while the form is up
// and none of them is torn down. On Android all four are the same manager anyway.
// Not while one is still being created: the in-flight job identifies its own form only
// by this id, so a second form opened underneath it would be the one closed and the
// wrong profile handed over.
if (chatModel.creatingProfileForInvitation.value) return
// Two taps before the modal renders would otherwise stack two modals sharing one id,
// after which close() could dismiss the wrong one. NotClosing, or re-opening the form
// right after backing out of it is a silent no-op until the animation ends.
// fullscreen: center nulls chatId, closing the chat the invitation is in; end leaves the
// compose picker live in the pane beside the form; start disposes the picker that opened it.
val modalManager = ModalManager.fullscreen
if (modalManager.hasModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return
if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return
modalManager.showModalCloseable(id = ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE) { close ->
CreateProfile(submitting = chatModel.creatingProfileForInvitation.value) { displayName, shortDescr, image ->
if (chatModel.creatingProfileForInvitation.value) return@CreateProfile
chatModel.creatingProfileForInvitation.value = true
withBGApi {
// On Main, like the pickers' own handlers: every call here suspends into IO, and the
// chat model is updated on Main by the receiver loop.
withApi {
try {
// The reassignment in onCreated resolves the invitation under whatever is active
// then, so remember what owns it now and check nothing moved underneath us.
val ownerUserId = chatModel.currentUser.value?.userId
val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image)
val newUser = controller.apiCreateProfileKeepingActive(rhId, profile) ?: return@withBGApi
val newUser = controller.apiCreateProfileKeepingActive(rhId, profile) ?: return@withApi
if (newUser.activeUser) {
// The core did not honour keepActiveUser and activated the profile - an older
// remote host ignoring the unknown field. Reassigning would now fail, so resync
// to what the host actually did and report it. Not switching_profile_error_message:
// that says the invitation was moved, and on this path it was not.
// The form is dismissed first: the app is about to be showing a different
// profile, and leaving the form on top of it invites a second attempt that
// would fail the same way.
withContext(Dispatchers.Main) {
if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close()
controller.changeActiveUser(newUser.remoteHostId, newUser.userId, null)
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
}
return@withBGApi
// An older remote host ignored the flag and activated it, so the reassignment
// would fail. Resync to what the host did and report it, with the form
// dismissed - the app is about to be showing a different profile.
if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close()
controller.changeActiveUser(newUser.remoteHostId, newUser.userId, null)
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
return@withApi
}
// Keep chatModel.users current even if onCreated's reassignment fails - it only
// refreshes when it actually switches. listUsers throws and withBGApi does not
// catch, so this cosmetic refresh is guarded; and it is applied on the main
// thread, where the receiver loop also updates this list.
// List the new profile even if the reassignment below fails. listUsers throws and
// withApi does not catch, so the cosmetic refresh is guarded.
if (chatModel.remoteHostId() == rhId) {
runCatching { controller.listUsers(rhId) }.getOrNull()?.let { updatedUsers ->
withContext(Dispatchers.Main) {
chatModel.users.clear()
chatModel.users.addAll(updatedUsers)
}
runCatching { controller.listUsers(rhId) }.getOrNull()?.let {
chatModel.users.clear()
chatModel.users.addAll(it)
}
}
// Hand over on the main thread: onCreated reassigns the chat or connection and
// switches the user, all of which update structures the receiver loop also
// writes to on Main - and Main is where both pickers ran this before it became
// a suspending callback. close() touches the modal stack, so it goes here too.
withContext(Dispatchers.Main) {
// A notification tap or a remote host switch can change the active user or the
// host while the profile is being created; reassigning then would resolve the
// invitation under the wrong profile. Stop, with the profile created.
if (chatModel.currentUser.value?.userId != ownerUserId || chatModel.remoteHostId() != rhId) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
return@withContext
}
// The form is gone - most likely the user backed out of it while the profile
// was being created. Don't move the invitation under a screen they have left,
// and don't report an error for something they did deliberately.
if (!modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) {
Log.i(TAG, "createProfileForInvitation: form closed before the invitation was moved, profile ${newUser.userId} left created")
return@withContext
}
close()
onCreated(newUser)
// onCreated resolves the invitation under whatever is active when it runs, and a
// notification tap or a host switch can have changed that while we were creating.
if (chatModel.currentUser.value?.userId != ownerUserId || chatModel.remoteHostId() != rhId) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
return@withApi
}
// Form gone - the user backed out of it. Don't move the invitation under a screen
// they left, and don't report an error for something they did on purpose.
if (!modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) {
Log.i(TAG, "createProfileForInvitation: form closed before the invitation moved")
return@withApi
}
close()
onCreated(newUser)
} finally {
chatModel.creatingProfileForInvitation.value = false
}
@@ -42,10 +42,9 @@ fun ComposeContextProfilePickerView(
val incognitoDefault = chatModel.controller.appPrefs.incognito.get()
val users = chatModel.users.map { it.user }.filter { u -> u.activeUser || !u.hidden }
val listExpanded = remember { mutableStateOf(false) }
// Not rememberSaveable: process death would skip the resetting finally and strand it true.
// Not rememberSaveable: process death would skip the resetting finally and strand it true
val changingProfile = remember { mutableStateOf(false) }
// Creating a profile keeps the rows disabled too - the reassignment runs after the form
// closes, and until it has, picking anything else moves the invitation twice.
// Creating counts as busy until the invitation has moved, which is after the form closes
val busy = changingProfile.value || chatModel.creatingProfileForInvitation.value
val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() }
@@ -107,9 +106,8 @@ fun ComposeContextProfilePickerView(
chatMoved = true
}
}
// Only switch profile if the chat was actually moved to it, otherwise the user
// would end up in another profile with the invitation left behind in this one.
// apiChangePreparedContactUser/apiChangePreparedGroupUser report the failure.
// Only switch if the chat moved, or the user ends up in another profile with the
// invitation left behind. apiChangePrepared*User reports the failure itself.
if (chatMoved) {
chatModel.controller.changeActiveUser_(
rhId = newUser.remoteHostId,
@@ -117,10 +115,6 @@ fun ComposeContextProfilePickerView(
viewPwd = null,
keepingChatId = chat.id
)
// No chatId assignment here. changeActiveUser_ passes keepingChatId, and
// updateChats only clears chatId when the chat is missing from the reloaded
// list - so on success this would be a no-op, and in the one case the guard
// does fire it would re-point the chat view at a chat that is not there.
if (chatModel.currentUser.value?.userId != newUser.userId) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.switching_profile_error_title),
@@ -134,8 +128,7 @@ fun ComposeContextProfilePickerView(
}
fun changeProfile(newUser: User) {
// Also set here, not only in changeProfileTo: withApi dispatches, so between the tap
// and the coroutine starting the row would still be enabled.
// Also here, not just in changeProfileTo: withApi dispatches, leaving a gap after the tap
changingProfile.value = true
withApi { changeProfileTo(newUser) }
}
@@ -257,8 +250,7 @@ fun ComposeContextProfilePickerView(
.fillMaxWidth()
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp)
.clickable(enabled = !busy, onClick = {
// Same guard as every other row: the flag is live state the receiver loop
// flips, so it can turn true between this row being laid out and the tap.
// Live state the receiver loop flips, so it can turn true after the row was laid out
if (!chat.chatInfo.profileChangeProhibited) {
createProfileForInvitation(rhId) { changeProfileTo(it) }
} else {
@@ -294,7 +286,6 @@ fun ComposeContextProfilePickerView(
Modifier
.heightIn(max = MAX_USER_PICKER_HEIGHT)
.background(MaterialTheme.colors.surface)
// The rows are already unclickable while busy; this is the only thing that says so.
.alpha(if (busy) 0.6f else 1f),
reverseLayout = true,
maxHeight = remember { mutableStateOf(MAX_USER_PICKER_HEIGHT) },
@@ -118,31 +118,18 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id
/** Like [isLastModalOpen], except that a modal already dismissed and only waiting out
* its close animation does not count as open: [closeModal] leaves it in [modalViews]
* and stages its index in [toRemove]. A caller that decides whether to close on "is my
* modal still the last one?" needs this, or a back-tap during a long operation makes it
* pop the screen underneath as well.
*
* Separate from [isLastModalOpen] rather than folded into it, because that one is also
* used to decide when to tear down secondary chats, where the existing behaviour is
* relied on. */
/** [isLastModalOpen], except that a modal already dismissed and only waiting out its
* close animation does not count - [closeModal] leaves it in [modalViews] and stages
* its index in [toRemove]. A caller deciding whether to close on "is my modal still the
* last one?" needs this, or a back-tap during a long operation pops the screen beneath
* as well. Kept separate because [isLastModalOpen] also gates secondary chat teardown,
* which relies on the existing behaviour. */
fun isLastModalOpenNotClosing(id: ModalViewId): Boolean {
var i = modalViews.size - 1
while (i >= 0 && i in toRemove) i--
return i >= 0 && modalViews.getOrNull(i)?.id == id
}
/** [hasModalOpen] with the same exclusion as [isLastModalOpenNotClosing]: a modal
* dismissed but still animating out does not count. Without it, re-opening something
* straight after closing it is a silent no-op for the length of the animation. */
fun hasModalOpenNotClosing(id: ModalViewId): Boolean {
for (i in modalViews.indices) {
if (i !in toRemove && modalViews.getOrNull(i)?.id == id) return true
}
return false
}
fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal(id = id, forceAnimated = forceAnimated) { close ->
ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() })
@@ -300,16 +300,14 @@ fun ActiveProfilePicker(
val selectedProfile by remember { chatModel.currentUser }
val searchTextOrPassword = rememberSaveable { search }
// Intentionally don't use derivedStateOf in order to NOT change an order after user was selected.
// Keyed on the profile count as well so a profile created from this picker appears in it
// without relying on the composition being torn down and rebuilt by the form on top.
// Keyed on the users too, or a profile created from this picker is missing from it.
val filteredProfiles = remember(searchTextOrPassword.value, chatModel.users.size, chatModel.currentUser.value?.userId) {
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
// Creating a profile for this invitation keeps the picker busy until the connection has
// been moved onto it, not just until the profile exists.
// Busy until the connection has moved onto the new profile, not just until it exists
val busy = switchingProfile.value || chatModel.creatingProfileForInvitation.value
LaunchedEffect(busy) {
@@ -320,9 +318,8 @@ fun ActiveProfilePicker(
false
}
}
// Creating skips the 500ms grace: the picker is recomposed from scratch when the form
// closes, so progressByTimeout starts again from false and would leave the rows looking
// idle while the invitation is being moved.
// Creating skips the 500ms grace: the picker is recomposed when the form closes, so
// progressByTimeout restarts from false and the rows would look idle
val showProgress = progressByTimeout || chatModel.creatingProfileForInvitation.value
suspend fun selectProfileAsync(user: User) {
@@ -332,20 +329,15 @@ fun ActiveProfilePicker(
if (contactConnection != null) {
updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
// The connection was not moved. Leave the picker open instead of switching or
// dismissing: a profile just created for this invitation would otherwise be
// stranded with nothing pointing at it, and this call provisions a new queue, so
// it is what fails offline. apiChangeConnectionUser reports the failure itself
// except when sendCmdWithRetry gives up, which is either the user cancelling the
// retry or the command being cancelled.
// Not moved - leave the picker open rather than stranding a profile just created
// for this invitation. This call provisions a new queue, so it is what fails offline.
if (updatedConn == null) return
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateContactConnection(rhId, updatedConn)
updateShownConnection(updatedConn)
}
}
// Set only once the connection is known to have moved, so a failed reassignment
// does not silently turn the app-wide incognito default off.
// Only once the connection has moved, or a failure silently clears the app-wide default
appPreferences.incognito.set(false)
controller.changeActiveUser_(
@@ -367,9 +359,8 @@ fun ActiveProfilePicker(
}
}
// Only if this picker is still the top of its stack. The reassignment above can
// wait indefinitely on the retry alert, and back is not blocked while it does, so
// an unconditional close() here would pop whatever the user moved on to.
// Only if still the top: the call above can wait indefinitely on the retry alert
// while back stays enabled, and an unconditional close() would pop the wrong screen.
if (ModalManager.start.isLastModalOpenNotClosing(ModalViewId.ACTIVE_PROFILE_PICKER)) {
close()
}