You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Screens, view models, workers and receivers read a global "current user" (CurrentUserProviderOld / CurrentUserProvider). Anything that changed it switched open screens to another account, or
mixed the data of account A with the credentials of account B: an incoming call, a notification tap, the account switcher, share-to, a deep link. The providers also had their own bugs:
non thread-safe caching
runBlocking on the main thread
writes during reads (self-heal in getActiveUser())
stale values
The HTTP layer had the same problem one level lower: one shared cookie store kept server session cookies per server, so with several accounts on the same server, requests of one account were
authenticated with another account's session.
Solution
Every screen, view model, worker and receiver is bound to an explicit account, passed as BundleKeys.KEY_INTERNAL_USER_ID (a Long). An open screen stays on its account.
The global user is now a "default account": the last used one, stored as the current flag. It is read only by entry points: app start, share-to, deep links without an account, the
account switcher and the phone book integration. It changes on user actions: the account switcher, a notification tap, a deep link, opening the conversation list of an account, and joining a
call. The conversation list follows it when it comes back from the background.
Theming is per account: screens use the server colors of their own account. The color scheme cache is keyed by the theming capability, so accounts on the same server with different colors
don't share one entry, and changed server colors apply after the next capabilities sync.
Requests are authenticated by their account's credentials only:
Requests with an Authorization header neither send nor store cookies (CredentialsCookieInterceptor).
Images of the account's own server (chat previews, quote and link previews, the media viewer, conversation avatars of open conversations) send the account's credentials.
Credentials are only sent after an origin check (scheme, host, port, base path) via UriUtils.isOnServer(), never after a URL prefix check.
RemoteWipeInterceptor only reacts to a 401 for a request with credentials, and removes exactly the account with those credentials. Before, it removed the first account on the server for
any 401.
The TLS client certificate is chosen by the server of the connection, not by the default account.
Clean-up and data layer:
The current user providers are replaced by DefaultAccountProvider.
Reading the active user no longer writes. A one-time repair at startup, a single SQL statement, fixes several accounts marked as active.
Background jobs and Settings only write the columns they change (Room partial entities). They can no longer reset the default account or the token by saving an old copy of the account.
A reauthorization only stores the new token in the account that logged in (by login name and server), and only if it's the account being reauthorized. Otherwise the login screen asks to log
in with the right account.
Other related fixes found along the way
Switching accounts no longer opens a second app instance, and no longer briefly shows the previous account's conversations.
A created conversation opens only once, and only while the contacts screen is visible. Links to the own server open for the screen's account.
An edited status message survives rotation. The account switcher's status belongs to the current default account.
After picture in picture during a call for another account, the list shows that account.
The share-to chooser keeps the chosen account, uses the list's account, and passes on the shared files. The list no longer relaunches an intent received from other apps as-is (lint UnsafeIntentLaunch).
Sharing to Talk without an account opens the login instead of closing silently.
Renaming a conversation from the conversation list works again.
The current room (for joining and calls) is matched by token and account, as accounts on the same server share the tokens of their common conversations.
The login screen doesn't start a second login when it's recreated.
The file browser and the text viewer are BaseActivitys, so they get the screen lock, FLAG_SECURE and their account's theme; the sorting dialog uses the host's theme.
The media viewer and shared items authenticate with the login name instead of the user id.
Profile and Settings close when their account was removed, instead of crashing.
Crashes: CallActivity finishing in onCreate(); DialogBanListFragment on rotation; restored fragments accessing the chat view model.
isDialing is reset when a call screen closes before setup.
An upload whose account was removed fails cleanly.
A camera or picker result for the conversation avatar is no longer lost after process death.
The address search uses the chat's account theme.
Injected view models are real view models, so they survive rotation and are cleared.
MessageSearchActivity (unused) is removed.
How to get the account
Where
How
Start an activity
Always put KEY_INTERNAL_USER_ID (the user.id, a Long). For chats, use ChatActivity.createIntent(context, userId, roomToken, extras). A missing id fails in debug builds (check) and is logged as an error in release builds.
Activity (BaseActivity)
In onCreate(): super.onCreate() → inject(this) → user = setUpBoundUserOrFinish() ?: return → the rest. This loads the account, applies its theme, and finishes the activity if the account doesn't exist. Only entry points set override val allowsDefaultAccount = true, and then fall back to the default account.
View model
@AssistedInject constructor(…, @Assisted user: User) with an @AssistedFactory interface Factory { fun build(user: User): X }. In the activity: val viewModel by assistedViewModels { factory.build(user) }, used only after setUpBoundUserOrFinish(). In Compose: viewModel(key = "x-${user.id}", factory = ViewModelFactoryWithParams(X::class.java) { factory.build(user) }). Never read the default account in a view model.
Observe account changes (capabilities, token, …)
userManager.userFlow(id). For a one-time read: userManager.getUserWithId(id).
Fragment / dialog
Pass the User (Parcelable) or its id as fragment arguments via newInstance(…), never as constructor parameters (they break recreation). Theme: viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils).
Custom view / adapter
Take the account from the host screen. Theme: hostViewThemeUtils(context, fallback).
Worker
Put the id into the input data (putLong(KEY_INTERNAL_USER_ID, user.id)). In the worker: userManager.getUserWithId(inputData.getLong(KEY_INTERNAL_USER_ID,0L)), and fail the work if it's null.
Receiver / notification action / service
Put the id into the (pending) intent. Read it with getLongExtra(KEY_INTERNAL_USER_ID, 0L), and stop if no account is found. There's no fallback to the default account.
Entry point without an account context
DefaultAccountProvider.getDefaultUser() (suspend) or getDefaultUserBlocking(). Make an account the default only on user actions, with userManager.setUserAsActive(user).
Theme outside BaseActivity
ViewThemeUtilsFactory.forUser(user).
Save account data
Never save a whole User copy that was read earlier: it overwrites current and token. Use the partial updates (userManager.updateCapabilities, updateExternalSignalingServer, updateDisplayName, updateClientCertificate, updateCredentials). For new columns, add a partial entity class in data/user/model/UserPartialUpdates.kt and an @Update(entity = UserEntity::class) method.
Navigate logical layers of code changes, visualize relationships, and explore their blast radius.
Note
Reviews paused
It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.
Use the following commands to manage reviews:
@coderabbitai resume to resume automatic reviews.
@coderabbitai review to trigger a single review.
Use the checkboxes below for quick actions:
▶️ Resume reviews
🔍 Trigger review
📝 Walkthrough
Walkthrough
The change makes account identity explicit across activities, view models, conversation flows, and background work. It adds default-account lookup, per-user data updates, and repair for multiple active accounts. Account IDs are passed through navigation and worker inputs. Feature view models receive their account user directly. Theme utilities can resolve the host activity’s theme. The message-search activity and related UI resources are removed. Login reauthorization now targets a specified account, and credentialed HTTP requests do not send or store session cookies.
Priority: ⬆️ High
Merge Risk:🟡 Moderate · up to 06ace
Resolve image credential isolation, certificate endpoint matching, and stale credentials in restored poll dialogs before merging. These gaps can expose account-specific images or credentials and cause authentication failures. The historical rename, profile, theme, sharing, and login-recreation concerns are addressed.
Security Architecture Review
Security architecture risk:🟡 Moderate · up to 06ace
Explicit account binding improves isolation, but shared image caching has not been aligned with account-bound authentication. Authenticated previews also inherit existing unencrypted-transport and account-removal edge cases.
Retained concerns
Medium · security · inferred: New account-authenticated image consumers retain the application-wide cache without representing account identity in their cache configuration. For an identical URL whose response depends on account credentials, one account can potentially receive another account's cached image without a fresh authorization check. The shared cache existed before this PR, but newly credentialed consumers expand the content entering it. This concern is limited to colliding URLs within the same installation; account-specific URLs naturally reduce exposure.
Security review details
Security Blast Radius
inferred — Image-cache disclosure is bounded to accounts using the same application installation and colliding image URLs. Network authentication paths carry the selected account's application credentials; their downstream authority depends on that account's server permissions. The inspected paths do not establish administrative, cross-installation, or infrastructure-wide authority.
Security Findings and Attack Paths
inferred — The retained image-cache finding concerns a request authenticated as account A populating shared image state that account B can subsequently consume for the same URL. Newly credentialed consumers are confirmed, but no runtime cache-collision demonstration was performed. URLs embedding distinct account identifiers reduce this attack path.
observed — Matching thumbnails for an HTTP-configured account receive Authorization over cleartext transport. Thumbnail authentication is new, but HTTP account support and network policy already allowed this transport, so this is an additional credentialed consumer of an existing account exposure rather than a newly introduced TLS bypass. An HTTPS account's initial HTTP thumbnail fails the scheme check.
inferred — The retained denial-of-service path attributes a terminal 401 after redirection to the original request's account and schedules local account removal, even when the wipe check returns false. Original-request attribution and unconditional removal predate this PR; the head narrows candidate selection with exact credentials and server matching. No worsened maximum deletion scope was established.
inferred — The retained redirect-exposure finding applies to newly authenticated link thumbnails. This pass confirms initial-origin filtering and use of the shared HTTP client, but did not establish redirect-time Authorization handling in the pinned dependencies. Redirect following alone is insufficient evidence that credentials cross origins or survive an HTTPS-to-HTTP redirect, so that consequence is not independently asserted here.
Trust Boundaries and Controls
observed — Credentialed requests omit session cookies and ignore response Set-Cookie, preventing another account's server session from overriding explicit authentication. Reauthorization also binds the browser-authenticated username and server to the requested internal account ID. Neither control partitions image-cache ownership.
Resilience and Maintainability Implications
inferred — Credential matching is a snapshot check, not an atomic fence through account deletion. A credential replacement after candidate resolution can still be followed by deletion scheduling using the same account ID. Concurrent duplicate suppression protects the successful path but can suppress recovery after failure before the durable marker. These mechanisms existed at the base; the PR improves identity selection without resolving these lifecycle weaknesses.
Hardening Proposals
proposed — Partition authenticated image caches by stable account identity, with appropriate invalidation on removal or credential changes. Avoid using raw credentials as cache identifiers. Validate isolation using identical URLs that return different images for two accounts.
proposed — Bind destructive 401 processing to the effective response origin and a current credential generation at the durable mutation boundary. Make duplicate suppression recoverable when processing fails before deletion ownership is persisted, and verify redirect and interruption behavior against the pinned HTTP dependencies.
🚥 Pre-merge checks | ✅ 3 | ❌ 1 | ❓ 1
❌ Failed checks (1 warning, 1 inconclusive)
Check name
Status
Explanation
Resolution
Docstring Coverage
⚠️ Warning
Docstring coverage is 10.21% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 421 functions across 90 files.
Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check
❓ Inconclusive
The title relates to account handling, but "New user handling" is too broad and does not identify the primary change: binding screens and background work to explicit accounts.
Use a specific title such as "Bind screens and background work to explicit accounts".
✅ Passed checks (3 passed)
Check name
Status
Explanation
Description check
✅ Passed
The description clearly explains the problem, solution, related fixes, testing scope, and checklist. It omits the template's screenshots and TODO sections, but the required change details are otherwis…
Linked Issues check
✅ Passed
Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check
✅ Passed
Check skipped because no linked issues were found for this pull request.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Key the theme cache by the theme inputs, not only baseUrl.
When two accounts share a baseUrl but have different themingCapability values, the first account populates this cache entry. The new ViewThemeUtilsFactory.forUser then gives the second account the first account’s colors. Include the account and relevant capability state in the cache key, or calculate schemes without this cache. The new per-user factory makes this existing cache behavior affect account-scoped views.
ℹ️ Review info⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Advanced
Run ID: ccc46ac3-e2bd-4831-a031-4f07e432752e
📥 Commits
Reviewing files that changed from the base of the PR and between f704b95 and 90f3bd5.
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.
Chat and call screens read the global active user instead of the
account they were opened for. Any setUserAsActive() while such a screen
was open (e.g. an incoming call for another account) silently switched
it to the other account or mixed data and credentials of both.
- ChatActivity/ChatViewModel are bound to the user id passed in the
intent (KEY_INTERNAL_USER_ID); all chat launches go through
ChatActivity.createIntent() and pass the user id
- CallActivity, RaiseHandViewModel and CallRecordingViewModel use the
user of the call
- NotificationWorker no longer switches the active account on incoming
calls
- UserManager.userFlow(id) observes a single user
- getActiveUser() no longer writes to the database on every read; the
"multiple active users" self-heal runs once at app start
- setUserAsActive() publishes the stored row
- CurrentUserProviderOldImpl: fix racy cache initialization
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
- Switching accounts opens the conversation list of the new account in
a cleared task, so no screen of the previous account stays in the
back stack (account dialog, SwitchAccountActivity, ecosystem account
handoff)
- ConversationsListActivity keeps its account across new intents and
opens a fresh instance for another account
- Notification receivers require the user id instead of falling back
to the active account; the recording actions now pass it
- LeaveConversationWorker, UploadAndShareFilesWorker and
DownloadFileToCacheWorker use the user id from their input data
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
ConversationsListViewModel, ConversationTagsViewModel and
ThreadsOverviewViewModel get the user of their screen via assisted
injection instead of reading the active account at construction time.
FilterConversationFragment receives the user as argument.
Opening the conversation list of an account makes it the last used
(default) account, which the account switcher and status views use.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
ConversationInfoActivity and ConversationInfoEditActivity use the user
id they were started with, and pass it on to the screens they open.
ConversationInfoEditViewModel and DialogBanListFragment get the user
from their screen.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…account
SharedItemsActivity, MediaViewerActivity and RemoteFileBrowserActivity
use the user id they were started with; all launchers pass it.
RemoteFileBrowserItemsViewModel gets the user via assisted injection.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
- MessageSearchViewModel gets the user of its screen
- The poll dialogs use the user they are created with (the main poll
dialog already received it but ignored it) and pass it to their
view models
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Scheduled messages, reminders, mention autocomplete, location sharing,
translation and context chat use the user of the chat they belong to
instead of the active account.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…eir account
ContactsViewModel, ConversationCreationViewModel and
OpenConversationsViewModel get the user of their screen via assisted
injection. Contacts, conversation creation, open conversations and
invitations use the user id they were started with; all launchers pass
it.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
SettingsActivity, ProfileActivity and DiagnosisActivity use the user id
they were started with; the conversation list and settings pass it.
DiagnosisViewModel gets the user via assisted injection and the
diagnosis account section shows that user.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Screens were themed with the server colors of the active account, so a
screen of another account (e.g. an incoming call for it) used the wrong
colors.
- ViewThemeUtilsFactory creates ViewThemeUtils for a given account
- Activities bound to an account apply its theme right after injection
(BaseActivity.applyUserTheme)
- Fragments, dialogs and views use the ViewThemeUtils of their hosting
activity (hostViewThemeUtils)
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
All screens, workers and receivers now use the account they were
started for, so the "current user" providers are only needed where
there is no account context (entry points, share-to, account switcher,
default theme). Replace CurrentUserProviderOld and CurrentUserProvider
by DefaultAccountProvider, which makes that meaning explicit, and
rename UserManager.getCurrentUser()/currentUserFlow to
getDefaultUser()/defaultUserFlow.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…s after switching
The singleton conversations repository kept the observed account in a
global state that getRooms() only updated asynchronously, so the room
list of a newly opened account first emitted the conversations of the
previously shown account. roomListFlow() now takes the account id.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
FLAG_ACTIVITY_NEW_TASK never matches an existing task because the app's
activities have an empty task affinity, so FLAG_ACTIVITY_NEW_TASK |
FLAG_ACTIVITY_CLEAR_TASK started a second app instance. Use
FLAG_ACTIVITY_CLEAR_TOP instead, which recreates the conversation list
of the current task for the new account and closes all screens above
it.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
🚧 Files skipped from review as they are similar to previous changes (1)
app/src/main/res/values/strings.xml
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.
The reason will be displayed to describe this comment to others. Learn more.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR eliminates reliance on a mutable global “current user” by binding screens/workers/receivers to an explicit account (internal user id), introduces a dedicated DefaultAccountProvider for true entry points, and updates theming + persistence patterns accordingly.
Changes:
Replace CurrentUserProvider* usage with explicit User/KEY_INTERNAL_USER_ID plumbing across activities, view models, workers, receivers, and UI components.
…ated
The view model of BrowserLoginActivity survives configuration changes, but
onCreate() starts the login on every recreation. The second login request
replaced the pending one, so the app polled a login session the browser was
not authorizing.
The view model now starts a login only once, so a recreated activity continues
the pending login. After process death, the login is started again.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
When another account than the one to reauthorize logged in and it was not set
up in the app yet, the reauthorization started the account verification and
added it as a new account, leaving the account to reauthorize unauthorized.
During a reauthorization, an account that is not set up is now reported as a
different account as well.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…session cookie
All accounts share one HTTP client with one cookie store, which keeps cookies
per server only. A session cookie set by a request of one account was sent
along with the requests of another account on the same server, and the server
treated them as the first account: e.g. marcel2's conversation list showed
marcel's conversations and its own conversations returned 404. Clearing the
cookies on account switches didn't help once several accounts send requests
at the same time.
Requests with an Authorization header now neither send nor store cookies, so
they are authenticated by their own credentials only.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
LocalLoginDataSource.updateUser() reported success even if no row was updated,
e.g. when the account was removed between looking it up and storing the new
credentials. It now only reports success if the credentials were stored.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…isible
The contacts screen collected the created room state as long as the activity
existed, so it could start the chat while in the background. It now collects
it only while started; a conversation created meanwhile is opened when the
screen is started again.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
TLS certificate selection remains global, status restoration can skip initialization after process death, and profile resume still blocks the main thread.
rememberSaveable can restore isInitialized = true after process death, but the activity-scoped ViewModel is then a new instance with empty fields. If this sheet is restored open, initialization is skipped and the status editor shows blank/default state. Keep the initialization flag in the ViewModel (or its SavedStateHandle) so the flag and edited state share the same lifetime.
Avoid synchronous Room lookup on the main thread during resume
This executes a Room lookup synchronously on the main thread every time the activity resumes. A slow or contended database blocks rendering and can cause an ANR, reintroducing the main-thread runBlocking problem this account migration is intended to remove. Load/observe the bound account from a lifecycle coroutine and continue the profile refresh after it is available.
RemoteWipeInterceptor removed the first account on the server of any request
answered with 401, even if the request carried no credentials or those of
another account, and even if the server hadn't requested a wipe. A 401 for an
image loaded without credentials therefore removed an account whose
credentials were still valid, and with several accounts on the same server it
could remove the wrong one.
It now only reacts to a 401 for a request with credentials, and resolves the
account by exactly those credentials.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Chat media previews, quote thumbnails, link previews of the own server, the
media viewer and conversation avatars of open conversations were loaded
without credentials. They only worked when the shared cookie store happened to
hold a server session, which could belong to another account on the same
server, and failed since authenticated requests no longer store session
cookies.
They now send the credentials of the screen's account, only for URLs of its
own server.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
KeyManager always offered the client certificate of the default account. Since
screens and background work are bound to their account, requests of other
accounts are common, and they offered the wrong certificate or none, so mutual
TLS failed for them.
The certificate is now chosen by the server of the connection: the one used by
the accounts on it. Only if accounts on the same server use different
certificates, the default account decides, as the TLS connection is shared.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
URL-prefix authentication checks can expose credentials or misidentify remote wipes, and new synchronous database lookups block Android main-thread entry points.
rememberSaveable restores isInitialized = true after process death, but the activity-scoped ViewModel is newly created with empty fields. When the sheet is restored, initialization and predefined-status loading are skipped, leaving stale/blank state. Keep the initialization flag in the ViewModel (which survives rotation but resets after process death), or persist the edited state with SavedStateHandle.
WorkManager invokes ListenableWorker.startWork() on the main thread, so this runBlocking waits there for the Room lookup before the Rx work is scheduled. Make this a CoroutineWorker or compose the lookup asynchronously into the returned future.
onResume() runs on the main thread, so this runBlocking waits synchronously for a Room query on every resume and can stall rendering. Load the bound account in a lifecycle coroutine (or observe userFlow(id)) and continue the profile refresh after the suspend call completes.
runBlocking in receiver blocks main-thread reply handling
BroadcastReceiver.onReceive() executes on the main thread, and this runBlocking now waits there for Room I/O before handling the reply. Use goAsync() and perform the account lookup and reply work in an application coroutine, finishing the pending result afterward.
runBlocking in receiver blocks main-thread action handling
BroadcastReceiver.onReceive() executes on the main thread, so this runBlocking synchronously waits for Room I/O. Use goAsync() and perform the account lookup and action in an application coroutine, then finish the pending result.
runBlocking in receiver blocks main-thread action handling
BroadcastReceiver.onReceive() executes on the main thread, so this runBlocking synchronously waits for Room I/O. Use goAsync() and perform the account lookup and action in an application coroutine, then finish the pending result.
runBlocking in receiver blocks main-thread action handling
BroadcastReceiver.onReceive() executes on the main thread, so this runBlocking synchronously waits for Room I/O. Use goAsync() and perform the account lookup and action in an application coroutine, then finish the pending result.
Splits the lookup of the account whose credentials a request carries from
building the wipe candidate, and replaces the chain of early returns.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Whether an image request got the account's credentials was decided by
checking whether its URL starts with the server's base URL. That also matches
other hosts like https://cloud.example.com.attacker.test for
https://cloud.example.com, so e.g. a link preview image could send the
account's credentials to a foreign host.
UriUtils.isOnServer() now compares scheme, host, port and base path, and is
used wherever credentials are attached based on the URL.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The media viewer and the shared items screen built their credentials from the
user id instead of the login name. For accounts whose login name differs from
the user id, e.g. an email address, the requests were only authenticated by a
shared session cookie and got 401 since authenticated requests no longer use
cookies. They now use the login name.
A failed response is now reported as an HttpException instead of a
NullPointerException on the missing body.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
rememberSaveable also survives process death, while StatusMessageViewModel is recreated with empty flows. If the process dies with this sheet open, isInitialized is restored as true, so init, backup detection, and predefined-status loading are skipped and the restored sheet shows blank/default state. Keep the initialization guard in the ViewModel (or a SavedStateHandle) so rotation preserves edits but a newly created ViewModel initializes normally.
Matching only the hostname merges distinct TLS endpoints. Accounts for https://cloud.example.com and https://cloud.example.com:8443 are treated as the same server, so this can offer the certificate configured for one endpoint to the other (or choose the default account's alias when both differ). Include the peer port and compare it with each base URL's effective port.
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.
RemoteWipeInterceptor is an application interceptor, so it sees the response
after redirects. It resolved the account from the original request, which
still carries the credentials after a redirect to another host, although
OkHttp dropped them for the redirected request that got the 401. It now uses
the request of the response.
Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bind screens and background work to their account
Problem
Screens, view models, workers and receivers read a global "current user" (
CurrentUserProviderOld/CurrentUserProvider). Anything that changed it switched open screens to another account, ormixed the data of account A with the credentials of account B: an incoming call, a notification tap, the account switcher, share-to, a deep link. The providers also had their own bugs:
runBlockingon the main threadgetActiveUser())The HTTP layer had the same problem one level lower: one shared cookie store kept server session cookies per server, so with several accounts on the same server, requests of one account were
authenticated with another account's session.
Solution
BundleKeys.KEY_INTERNAL_USER_ID(a Long). An open screen stays on its account.currentflag. It is read only by entry points: app start, share-to, deep links without an account, theaccount switcher and the phone book integration. It changes on user actions: the account switcher, a notification tap, a deep link, opening the conversation list of an account, and joining a
call. The conversation list follows it when it comes back from the background.
don't share one entry, and changed server colors apply after the next capabilities sync.
Authorizationheader neither send nor store cookies (CredentialsCookieInterceptor).UriUtils.isOnServer(), never after a URL prefix check.RemoteWipeInterceptoronly reacts to a 401 for a request with credentials, and removes exactly the account with those credentials. Before, it removed the first account on the server forany 401.
DefaultAccountProvider.in with the right account.
Other related fixes found along the way
UnsafeIntentLaunch).BaseActivitys, so they get the screen lock,FLAG_SECUREand their account's theme; the sorting dialog uses the host's theme.CallActivityfinishing inonCreate();DialogBanListFragmenton rotation; restored fragments accessing the chat view model.isDialingis reset when a call screen closes before setup.MessageSearchActivity(unused) is removed.How to get the account
KEY_INTERNAL_USER_ID(theuser.id, a Long). For chats, useChatActivity.createIntent(context, userId, roomToken, extras). A missing id fails in debug builds (check) and is logged as an error in release builds.BaseActivity)onCreate():super.onCreate()→inject(this)→user = setUpBoundUserOrFinish() ?: return→ the rest. This loads the account, applies its theme, and finishes the activity if the account doesn't exist. Only entry points setoverride val allowsDefaultAccount = true, and then fall back to the default account.@AssistedInject constructor(…, @Assisted user: User)with an@AssistedFactory interface Factory { fun build(user: User): X }. In the activity:val viewModel by assistedViewModels { factory.build(user) }, used only aftersetUpBoundUserOrFinish(). In Compose:viewModel(key = "x-${user.id}", factory = ViewModelFactoryWithParams(X::class.java) { factory.build(user) }). Never read the default account in a view model.userManager.userFlow(id). For a one-time read:userManager.getUserWithId(id).User(Parcelable) or its id as fragment arguments vianewInstance(…), never as constructor parameters (they break recreation). Theme:viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils).hostViewThemeUtils(context, fallback).putLong(KEY_INTERNAL_USER_ID, user.id)). In the worker:userManager.getUserWithId(inputData.getLong(KEY_INTERNAL_USER_ID,0L)), and fail the work if it's null.getLongExtra(KEY_INTERNAL_USER_ID, 0L), and stop if no account is found. There's no fallback to the default account.DefaultAccountProvider.getDefaultUser()(suspend) orgetDefaultUserBlocking(). Make an account the default only on user actions, withuserManager.setUserAsActive(user).BaseActivityViewThemeUtilsFactory.forUser(user).Usercopy that was read earlier: it overwritescurrentandtoken. Use the partial updates (userManager.updateCapabilities,updateExternalSignalingServer,updateDisplayName,updateClientCertificate,updateCredentials). For new columns, add a partial entity class indata/user/model/UserPartialUpdates.ktand an@Update(entity = UserEntity::class)method.Testing
UserManager,DefaultAccountProvider,ChatViewModel,ContactsViewModel,CapabilitiesFetcherLoginRepository(reauthorization with a different or unknown account),BrowserLoginActivityViewModel(login started once)CredentialsCookieInterceptor(MockWebServer),RemoteWipeInterceptor(401s without matching credentials remove no account)UriUtils.isOnServer(origin check),ClientCertificateSelector🏁 Checklist
/backport to stable-xx.x🤖 AI (if applicable)