Repository: tuskyapp/Tusky (language: kotlin)

Issue:
With multiple accounts, sometimes the wrong home timeline is shown
I have experienced a problem with multiple accounts set up in Tusky where after switching from account 2 (pixelfed) to account 1 (mastodon), the home timeline of account 2 (pixelfed) would be shown and even after switching back and forth between accounts it would stay that way. Only way to fix it seems to be deleting the account showing the wrong timeline from Tusky and adding it again.

I have seen this several times recently (since 24 beta and now in 24), not sure if I had it before. I have not found a way to reproduce it yet. I don't know if the networks are relevant, I added them just in case.

* * * *
- Tusky Version: 24
- Android Version: Lineageos 20 (Android 13)

- [x] I searched or browsed the repo’s other issues to ensure this is not a duplicate.

Old posts from other account shown on timeline, before posts from correct account.
### Detailed description

I have several accounts (let's call them A, B, C ... F) in Tusky on Android. Several of these are on Mastodon servers, and others are on GoToSocial servers. One of those GoToSocial servers, constellation.home.nora.codes, has extremely limited federation; the posts on that instance should exist on 2 other servers, exclusively (mutual federation, but none of us federate anywhere else). Let's call that account F.

However, sometimes when I add accounts to Tusky, some other accounts - say, B and D - show 30 - 32 old (May of this year) posts from that instance in their home timeline. Refreshing and clearing cache do nothing; logging out of F does nothing; logging out of B and D and back in *sometimes* fixes the issue. Reinstalling the app sometimes fixes the issue. Scrolling down shows posts from B or D's timeline below the 30 errant posts.

There is no way the instances for accounts B and D have any knowledge of posts from constellation.home.nora.codes, so I think this is purely a client issue.

### Steps to reproduce the problem

_No response_

### Debug information

Google Pixel 6a
Android version: 13
SDK version: 33
Tusky 25.2 (but has been happening since months ago)
constellation.home.nora.codes (GoToSocial) and types.pl (Mastodon)
Tusky 26 showing notifications tab from wrong account
### Detailed description

I'm using Tusky with three different Mastodon accounts (all different instances). Since the update to v26 (F-droid), the notifications tab for account_A@imaginair.es shows notifications for the latest other account to be opened.

I have tried to solve the issue by force stopping Tusky and clearing the app cache, same result. Checked imaginair.es which can sometimes be bottlenecked, it's up so shouldn't be the issue.

I imagine this has to do with the new notifications cache?

### Steps to reproduce the problem

1. Use Tusky v26 with multiple accounts
2. Switch between accounts
3. Open notifications tab

Expected: see notifications for the current account.
Issue: notifications for the latest used account shown instead.

### Debug information

Tusky 26.0

Device:

OnePlus ONEPLUS A6003
Android version: 10
SDK version: 29

Account:

@REDACTED@imaginair.es
Version: 4.3.0-alpha.3+glitch
Old toots stuck on top of home timeline
Hi,

After going on my profile and scrolling to an old toot (knew I tooted it 2022-07-25, so just scrolled there), my home timeline now has toots from roughly that period stuck on top - fresh toots starting ~8 screen sizes below them.

The stuck ones seem to be non-contiguous, I think I should have had more toots in home as it shows. The fresh ones are refreshed, but stay at their place below the stuck ones.

I tried purging the app cache and switching tabs and accounts, without success.

This is pretty obscure, not sure what other info could be useful here or how to reproduce it.. some slightly rusted android dev skills are available, so feel free to ask for any adb thing (or something else) that might help diagnose the problem :)

* * * *
- Tusky Version: 19.0
- Android Version: 12 (SQ3A.220705.003)
- Android Device: Pixel 6 Pro
- Mastodon instance (if applicable): mastodon.social

- [x] I searched or browsed the repo’s other issues to ensure this is not a duplicate (browsed issues for some pages).

Multiple accounts bug: boosts from one account's home timeline appear in another's
### Detailed description

I have multiple accounts connected to Tusky.
Account 1 follows maloki. Account 2 does not.
multiple boosts from maloki appeared today in Account 2's home timeline.
![Screenshot_20240428-213752_Tusky](https://github.com/tuskyapp/Tusky/assets/109424320/5023f3dc-d920-4566-81e4-126f69a7d6fa)
trying to view the post or the boosting account results in an error:
![Screenshot_20240428-212743_Tusky](https://github.com/tuskyapp/Tusky/assets/109424320/24a513cc-6903-424c-a4a2-92fb89da1d95)
![Screenshot_20240428-213803_Tusky](https://github.com/tuskyapp/Tusky/assets/109424320/c27ffdd2-240b-41a4-aa7e-038bc0679af9)


### Steps to reproduce the problem

1. Connect Tusky to multiple accounts
2. Have Account 1 follow Some User.
3. Browse the home timeline of Account 2
4. boosts from Some Account appear in Account 2's timeline


### Debug information

Tusky 25.0 beta 1

Device:

samsung SM-A125F
Android version: 12
SDK version: 31

Account:

@yuvalne@tooot.im
Version: 4.2.8


Discussion hints:

Possibly related to https://github.com/tuskyapp/Tusky/issues/4402 but somewhat different symptoms
Thank you so much for the info, we are aware of the issue and trying to find out what's happening
Thank you so much for the info, we are investigating!

related: #4554 #4402
Just wanted to verify, on closer inspection this is exactly the same issue as #4402 — the amount of notifications that are added above the relevant ones merely drown those out. So currently my notification tab for account_A@imaginair.es consists of

* a year's worth of notifications from account_C@vis.social
* a corresponding period's notifications from account_B@helvede.net
* account_A@imaginair.es notifications, even up to current

The order notifications from the accounts are shown in the account_A tab correspond with that in which they were opened after the latest update. The issue does not occur in accounts B or C notification tabs.
I like to add that in my case it's not only the notifications that are affected. 

My account_A's timeline doesn't work any longer at all:
 * it doesn't update and 
 * TL shows parts (frozen to the state when I installed the update 5d ago) of the TL of account_B AND some of account_C. 
 * Some of the notifications - also frozen to state of 5d ago - belong to account_D 
 * while the private messages are those of account_A as it should be.

Accounts B, C, D, E and F work fine afai noticed, but A is completely unusable atm.
I have the same issue. Account A is the first account in Tusky and the one selected when newly opening the app.
In A's notifications tab, the first 30 entries are notifications from account B, after that it's notifications from account A. Newly incoming notifications also join the list, but after the 30 entries from B.
Account B as well als 3 other accounts seem to be workig properly.
KISS solution to this (I'm embarrassed I only thought of it now)

1. Open the notifications tab of the affected account
2. Open the Filter menu
3. Uncheck all options and apply
4. Your notifcations are now empty.
5. Open the Filter menu and re-check the options you want
6. The app now only shows notifications from the relevant account

Hopefully this works for others as well!
I was recently affected by this issue and can confirm that @haverholm's  proposed workaround works.
> KISS solution to this (I'm embarrassed I only thought of it now)
> 
>     1. Open the notifications tab of the affected account
> 
>     2. Open the Filter menu
> 
>     3. Uncheck all options and apply
> 
>     4. Your notifcations are now empty.
> 
>     5. Open the Filter menu and re-check the options you want
> 
>     6. The app now only shows notifications from the relevant account
> 
> 
> Hopefully this works for others as well!

It worked for the notifications  but my start  timeline stayed frozen to the corrupted state of 21 days ago. 
I tried to remove the tabs and re-add them.  But even if I removed the start tab it stayed and appeared instead of the notifications content.

So I decided to try to **remove the affected account and re-added it** after that. 

Et voilá! The Account seems to work well again in the end. So that's my workaround I guess. And I can happily  switch back to my absolute favourite Fediverse app! :purple_heart: 


Those old toots stuck on top, could they be from a different account that is logged into Tusky or are they definitely from the right one, just in the wrong order?

(logging out and in again or clearing the app data completely will solve the problem temporarily)
Now that you ask, they actually are from the Home timeline of another account.

And I noticed something even more strange: going to their authors profile via their avatar on the toot brings me to a completely different profile - for every toot by the same author, I go to the same other profile, but multiple authors could go to the same profile (clicking "toby" on stuck toot always brings up "mmcc", clicking any of two different bots gives me Eugen, clicking me gives another account than the one for "toby")
Those stuck toots could belong to a logged-in account on an instance that is currently offline - maybe some weird form of "old cache + cannot refresh -> not cleared from view"?

They could also belong to a third logged-in account, from which the instance currently is online. Both those other instances are GoToSocial btw.
Ok so this seems to be another incarnation of the "ghost timeline" bug that was supposed to be fixed by https://github.com/tuskyapp/Tusky/commit/f419e83c16438456559163463785ec68ef98fdd3

Basically what happens is that the timeline of one account gets written to the database with the id of another.

It could be reproduced by logging out of one account while a refresh was still ongoing, but since that is fixed there must be another way that it can happen as well 🤔 
Right after writing this report, I got this bug with another user, this time with a regular post, non-boost. trying to view the posting account results in the same error.
Do you remember what you did before this happened? Are those posts that are shown on the wrong account from the top of the right accounts timeline, or from randomly in between? How many posts are multiple? 1-2 or more like 10-20 or even more?
no need to do anything in particular for this to happen - this occurs even after switching to Account 1 and back, or after closing the app and reopnening.
the posts are in random bursts in bewteen. there's 60-something posts from the correct account counting from the newest point of the timeline, then four wrong posts. then ten correct posts, then seven wrong ones.
they also appear consistently at the same point of the timeline. maybe that means only the newest posts in the timeline were refreshed and everything else stayed the same, I don't know. 
I'm not sure if and how to fully reload the home timeline, even if I force stop the app the wrong posts are in exactly the same place, idk if Tusky does some caching but if it does that's probably why
Yes there is a cache, to remove it you will have to log out & login again for all affected accounts. 
Once the posts are in the wrong timeline they will stay there.
The question is how do they end up there in the first place 🤔 
then assuming all wrong posts were fetched at the same time, it will be quite hard to pinpoint the action that happened right before it. the first wrong posts are from 11 hours ago and they appear in the correct spot in the timeline (i.e. above posts that are 12 hours old but below ones that are 10 hours old). I also switched to Account 2 multiple times throughout the day, but I only scrolled down enough to see the wrong posts for the first time right before filing this report. 
I am having the (maybe) similar issue. Here is what I observed:

I've newly installed 25.1 from f-droid
Added 7 accounts to it (5 bots posting foxes, don't judge me!)
Then some weird issue occurred.

My main account would have 30 older posts (all were 1d old, collection of boosts and posts) on top that never moved. New posts would appear below them.
I logged out of it and back in, which fixed it on that account.
But then those 30 posts suddenly appeared on another account where they shouldn't be. Just as described above. This repeats if I log out of the "newly broken account".

I didn't really find a pattern, other than that the posts are always the same if they end up on another account and will stay on top. I originally used "log in with browser", which is firefox, because only that had the passwords saved and I was too lazy.

Cleared the cache on the settings but didn't change anything.

Logged out of all accounts and readded them via "log in with tusky" but the same posts reappeared on one of the accounts. Was neither the first one added, nor the last. Actually it was specifically the 4th added but that's probably just random coincidence.

Not sure if any of that helps, but maybe some behaviour there will point to something. :3 

Oh and one more thing: It happened on my device running Android 13 but it seems to work fine on a tablet I have with Android... uh... 8.1. I will try to leave it installed as is and see if it fix itself but it stayed like this for a day already.
just happened again on the same two accounts (and in the same direction, so from Account 1 to Account 2 like before) on version 25.2 stable.
this time the posts are >2 days old so way harder to figure out what might have been the trigger. 
okay, happened again, this time with some specificity that may help:
I switched from Account 1 to Account 2. Account 2's feed loaded not at the top of the feed, but right below the first "Load more". The 7 posts following that "load more" were all from the feed of Account 1, dated to around 13 hours ago. (edit: it was actually 7 posts, then a single post from the correct account, then 11 more until the next "load more".)
Curiously enough, 13 hours ago I was asleep, so whatever caused this likely didn't happen as those were posted, but somewhat later than that.
Two not-so-common things to happen today were:
1. a notification for a report on Account 2, though this also happened a few days ago with the bug not happening again after those.
2. an from Tusky of a post created from Web on account 1.

maybe it's neither of those, but I really can't think of anything else.
aha! I think I found the trigger!
I got a notification for [this post](https://433.world/@swordsoprano/112768600382212736) while being on account 2. after clicking on the notification, opening the post, reacting and going back to the home feed, the very first post I see is a post from account 2, right after the "load more".
![Screenshot_20240711-190021_Tusky](https://github.com/tuskyapp/Tusky/assets/109424320/2d22d67e-0a63-4dd7-a11d-da75f0bd48ed)
it could be that the very act of switching accounts not via the top bar is at fault.
Aha, that's something to look into, good find!

Pull request: prevent mixup of account timelines
This does 2 things:

- Removes `AccountSwitchInterceptor`, the main culprit for the bug. APIs can no longer change their base url after they have been created. As a result they are not Singletons anymore.
- Additionally, I refactored how MainActivity handles Intents to make it less likely to have multiple instances of it active.

Here is how I could reliably reproduce the bug:

- Be logged in with account A and B
- Write a post with account A, cancel it before it sends (go into flight mode for that)
- Switch to account B
- Open the "this post failed to send" notification from account A, drafts will open
- Go back. You are in the MainActivity of account A, everything seems fine.
- Go back again. You are in the old, now broken MainActivity of account B. It uses the database of account B but the network of account A. Refreshing will show posts from A.

closes #4567 
closes #4554
closes #4402 
closes #4148
closes #2663
and possibly #4588


Test command: ./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptorTest" --tests "com.keylesspalace.tusky.network.RetrofitApiTest"

Fix patch:
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
index 894757a79e..787f78ef5e 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
@@ -363,6 +363,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
         }
     }
 
+    override fun onDestroy() {
+        cacheUpdater.stop()
+        super.onDestroy()
+    }
+
     /** Handle an incoming Intent,
      * @returns true when the intent is coming from an notification and the interface should show the notification tab.
      */
@@ -390,6 +395,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
         val accountRequested = tuskyAccountId != -1L
         if (accountRequested && tuskyAccountId != activeAccount.id) {
             accountManager.setActiveAccount(tuskyAccountId)
+            changeAccount(tuskyAccountId, intent, withAnimation = false)
+            return false
         }
 
         val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
@@ -567,7 +574,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
             }
         }
         startActivity(composeIntent)
-        finish()
     }
 
     private fun setupDrawer(
@@ -985,11 +991,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
         return false
     }
 
-    private fun changeAccount(newSelectedId: Long, forward: Intent?) {
+    private fun changeAccount(
+        newSelectedId: Long,
+        forward: Intent?,
+        withAnimation: Boolean = true
+    ) {
         cacheUpdater.stop()
         accountManager.setActiveAccount(newSelectedId)
         val intent = Intent(this, MainActivity::class.java)
-        intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true)
+        if (withAnimation) {
+            intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true)
+        }
         if (forward != null) {
             intent.type = forward.type
             intent.action = forward.action
@@ -1240,6 +1252,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
         fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
             return Intent(context, MainActivity::class.java).apply {
                 putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
+                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
             }
         }
 
@@ -1286,7 +1299,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
         fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
             return accountSwitchIntent(context, tuskyAccountId).apply {
                 putExtra(REDIRECT_URL, url)
-                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
             }
         }
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt
index 9c5520c3ce..478ab7e64d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt
@@ -40,7 +40,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
 import java.io.File
 import java.io.IOException
 import javax.inject.Inject
-import javax.inject.Singleton
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -93,7 +92,6 @@ class MediaTypeException : Exception()
 class CouldNotOpenFileException : Exception()
 class UploadServerError(val errorMessage: String) : Exception()
 
-@Singleton
 class MediaUploader @Inject constructor(
     @ApplicationContext private val context: Context,
     private val mediaUploadApi: MediaUploadApi
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt
index ffd4754141..7e8378588e 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt
@@ -35,13 +35,11 @@ import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.util.isHttpNotFound
 import java.util.concurrent.ConcurrentHashMap
 import javax.inject.Inject
-import javax.inject.Singleton
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
-@Singleton
 class InstanceInfoRepository @Inject constructor(
     private val api: MastodonApi,
     db: AppDatabase,
@@ -53,9 +51,6 @@ class InstanceInfoRepository @Inject constructor(
     private val instanceName
         get() = accountManager.activeAccount!!.domain
 
-    /** In-memory cache for instance data, per instance domain.  */
-    private var instanceInfoCache = ConcurrentHashMap<String, InstanceInfo>()
-
     fun precache() {
         // We are avoiding some duplicate work but we are not trying too hard.
         // We might request it multiple times in parallel which is not a big problem.
@@ -212,6 +207,9 @@ class InstanceInfoRepository @Inject constructor(
     companion object {
         private const val TAG = "InstanceInfoRepo"
 
+        /** In-memory cache for instance data, per instance domain.  */
+        private var instanceInfoCache = ConcurrentHashMap<String, InstanceInfo>()
+
         const val DEFAULT_CHARACTER_LIMIT = 500
         private const val DEFAULT_MAX_OPTION_COUNT = 4
         private const val DEFAULT_MAX_OPTION_LENGTH = 50
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
index b1ccfa4be1..db887585c5 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
@@ -26,9 +26,9 @@ import com.keylesspalace.tusky.entity.Attachment
 import com.keylesspalace.tusky.entity.Notification
 import com.keylesspalace.tusky.entity.Status
 import com.keylesspalace.tusky.json.GuardedAdapter
-import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
 import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.network.MediaUploadApi
+import com.keylesspalace.tusky.network.apiForAccount
 import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED
 import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT
 import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER
@@ -47,6 +47,7 @@ import java.net.InetSocketAddress
 import java.net.Proxy
 import java.util.Date
 import java.util.concurrent.TimeUnit
+import javax.inject.Named
 import javax.inject.Singleton
 import okhttp3.Cache
 import okhttp3.OkHttp
@@ -54,7 +55,6 @@ import okhttp3.OkHttpClient
 import okhttp3.logging.HttpLoggingInterceptor
 import retrofit2.Retrofit
 import retrofit2.converter.moshi.MoshiConverterFactory
-import retrofit2.create
 
 /**
  * Created by charlag on 3/24/18.
@@ -66,6 +66,18 @@ object NetworkModule {
 
     private const val TAG = "NetworkModule"
 
+    @Provides
+    @Named("defaultPort")
+    fun providesDefaultPort(): Int {
+        return 443
+    }
+
+    @Provides
+    @Named("defaultScheme")
+    fun providesDefaultScheme(): String {
+        return "https://"
+    }
+
     @Provides
     @Singleton
     fun providesMoshi(): Moshi = Moshi.Builder()
@@ -92,7 +104,6 @@ object NetworkModule {
     @Provides
     @Singleton
     fun providesHttpClient(
-        accountManager: AccountManager,
         @ApplicationContext context: Context,
         preferences: SharedPreferences
     ): OkHttpClient {
@@ -125,23 +136,22 @@ object NetworkModule {
                 builder.proxy(Proxy(Proxy.Type.HTTP, address))
             } ?: Log.w(TAG, "Invalid proxy configuration: ($httpServer, $httpPort)")
         }
-
-        return builder
-            .apply {
-                addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
-                if (BuildConfig.DEBUG) {
-                    addInterceptor(
-                        HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
-                    )
-                }
-            }
-            .build()
+        if (BuildConfig.DEBUG) {
+            builder.addInterceptor(
+                HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
+            )
+        }
+        return builder.build()
     }
 
     @Provides
     @Singleton
-    fun providesRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit {
-        return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
+    fun providesRetrofit(
+        httpClient: OkHttpClient,
+        moshi: Moshi
+    ): Retrofit {
+        return Retrofit.Builder()
+            .baseUrl("https://${MastodonApi.PLACEHOLDER_DOMAIN}")
             .client(httpClient)
             .addConverterFactory(MoshiConverterFactory.create(moshi))
             .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
@@ -149,20 +159,25 @@ object NetworkModule {
     }
 
     @Provides
-    @Singleton
-    fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create()
+    fun providesMastodonApi(
+        httpClient: OkHttpClient,
+        retrofit: Retrofit,
+        accountManager: AccountManager
+    ): MastodonApi {
+        return apiForAccount(accountManager.activeAccount, httpClient, retrofit)
+    }
 
     @Provides
-    @Singleton
-    fun providesMediaUploadApi(retrofit: Retrofit, okHttpClient: OkHttpClient): MediaUploadApi {
+    fun providesMediaUploadApi(
+        retrofit: Retrofit,
+        okHttpClient: OkHttpClient,
+        accountManager: AccountManager
+    ): MediaUploadApi {
         val longTimeOutOkHttpClient = okHttpClient.newBuilder()
             .readTimeout(100, TimeUnit.SECONDS)
             .writeTimeout(100, TimeUnit.SECONDS)
             .build()
 
-        return retrofit.newBuilder()
-            .client(longTimeOutOkHttpClient)
-            .build()
-            .create()
+        return apiForAccount(accountManager.activeAccount, longTimeOutOkHttpClient, retrofit)
     }
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt b/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt
new file mode 100644
index 0000000000..9db7ed6433
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt
@@ -0,0 +1,57 @@
+package com.keylesspalace.tusky.network
+
+import com.keylesspalace.tusky.db.entity.AccountEntity
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.create
+
+/**
+ * Creates an instance of an Api that will only make requests as the provided account.
+ * @param account The account to make requests as.
+ * When null, request without additional DOMAIN_HEADER will fail.
+ * @param httpClient The OkHttpClient to make requests as
+ * @param retrofit The Retrofit instance to derive the api from
+ * @param scheme The scheme to use. Only used in tests.
+ * @param port The port to use. Only used in tests.
+ */
+inline fun <reified T> apiForAccount(
+    account: AccountEntity?,
+    httpClient: OkHttpClient,
+    retrofit: Retrofit,
+    scheme: String = "https://",
+    port: Int? = null
+): T {
+    return retrofit.newBuilder()
+        .apply {
+            if (account != null) {
+                baseUrl("$scheme${account.domain}${ if (port == null) "" else ":$port"}")
+            }
+        }
+        .callFactory { originalRequest ->
+            var request = originalRequest
+
+            val domainHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER)
+            if (domainHeader != null) {
+                request = originalRequest.newBuilder()
+                    .url(
+                        originalRequest.url.newBuilder().host(domainHeader).build()
+                    )
+                    .removeHeader(MastodonApi.DOMAIN_HEADER)
+                    .build()
+            }
+
+            if (account != null && request.url.host == account.domain) {
+                request = request.newBuilder()
+                    .header("Authorization", "Bearer ${account.accessToken}")
+                    .build()
+            }
+
+            if (request.url.host == MastodonApi.PLACEHOLDER_DOMAIN) {
+                FailingCall(request)
+            } else {
+                httpClient.newCall(request)
+            }
+        }
+        .build()
+        .create()
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt b/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt
new file mode 100644
index 0000000000..c074895244
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt
@@ -0,0 +1,65 @@
+/* Copyright 2024 Tusky contributors
+ *
+ * This file is a part of Tusky.
+ *
+ * This program is free software; you can redistribute it and/or modify it under the terms of the
+ * GNU General Public License as published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky; if not,
+ * see <http://www.gnu.org/licenses>. */
+
+package com.keylesspalace.tusky.network
+
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.Timeout
+
+class FailingCall(private val request: Request) : Call {
+
+    private var isExecuted: Boolean = false
+
+    override fun cancel() { }
+
+    override fun clone(): Call {
+        return FailingCall(request())
+    }
+
+    override fun enqueue(responseCallback: Callback) {
+        isExecuted = true
+        responseCallback.onResponse(this, failingResponse())
+    }
+
+    override fun execute(): Response {
+        isExecuted = true
+        return failingResponse()
+    }
+
+    override fun isCanceled(): Boolean = false
+
+    override fun isExecuted(): Boolean = isExecuted
+
+    override fun request(): Request = request
+
+    override fun timeout(): Timeout {
+        return Timeout.NONE
+    }
+
+    private fun failingResponse(): Response {
+        return Response.Builder()
+            .request(request)
+            .code(400)
+            .message("Bad Request")
+            .protocol(Protocol.HTTP_1_1)
+            .body("".toResponseBody())
+            .build()
+    }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt
deleted file mode 100644
index 6aabaa13d6..0000000000
--- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-/* Copyright 2022 Tusky Contributors
- *
- * This file is a part of Tusky.
- *
- * This program is free software; you can redistribute it and/or modify it under the terms of the
- * GNU General Public License as published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
- * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
- * Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with Tusky; if not,
- * see <http://www.gnu.org/licenses>. */
-
-package com.keylesspalace.tusky.network
-
-import android.util.Log
-import com.keylesspalace.tusky.db.AccountManager
-import java.io.IOException
-import okhttp3.HttpUrl
-import okhttp3.Interceptor
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.Protocol
-import okhttp3.Request
-import okhttp3.Response
-import okhttp3.ResponseBody.Companion.toResponseBody
-
-class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor {
-
-    @Throws(IOException::class)
-    override fun intercept(chain: Interceptor.Chain): Response {
-        val originalRequest: Request = chain.request()
-
-        // only switch domains if the request comes from retrofit
-        return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) {
-            val builder: Request.Builder = originalRequest.newBuilder()
-            val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER)
-
-            if (instanceHeader != null) {
-                // use domain explicitly specified in custom header
-                builder.url(swapHost(originalRequest.url, instanceHeader))
-                builder.removeHeader(MastodonApi.DOMAIN_HEADER)
-            } else {
-                val currentAccount = accountManager.activeAccount
-
-                if (currentAccount != null) {
-                    val accessToken = currentAccount.accessToken
-                    if (accessToken.isNotEmpty()) {
-                        // use domain of current account
-                        builder.url(swapHost(originalRequest.url, currentAccount.domain))
-                            .header("Authorization", "Bearer %s".format(accessToken))
-                    }
-                }
-            }
-
-            val newRequest: Request = builder.build()
-
-            if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) {
-                Log.w(
-                    "ISAInterceptor",
-                    "no user logged in or no domain header specified - can't make request to " + newRequest.url
-                )
-                return Response.Builder()
-                    .code(400)
-                    .message("Bad Request")
-                    .protocol(Protocol.HTTP_2)
-                    .body("".toResponseBody("text/plain".toMediaType()))
-                    .request(chain.request())
-                    .build()
-            }
-
-            chain.proceed(newRequest)
-        } else {
-            chain.proceed(originalRequest)
-        }
-    }
-
-    companion object {
-        private fun swapHost(url: HttpUrl, host: String): HttpUrl {
-            return url.newBuilder().host(host).build()
-        }
-    }
-}


Test patch:
diff --git a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt b/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt
deleted file mode 100644
index 206e27d5ca..0000000000
--- a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package com.keylesspalace.tusky.network
-
-import com.keylesspalace.tusky.db.AccountManager
-import com.keylesspalace.tusky.db.entity.AccountEntity
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okhttp3.mockwebserver.MockResponse
-import okhttp3.mockwebserver.MockWebServer
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Before
-import org.junit.Test
-import org.mockito.kotlin.doAnswer
-import org.mockito.kotlin.mock
-
-class InstanceSwitchAuthInterceptorTest {
-
-    private val mockWebServer = MockWebServer()
-
-    @Before
-    fun setup() {
-        mockWebServer.start()
-    }
-
-    @After
-    fun teardown() {
-        mockWebServer.shutdown()
-    }
-
-    @Test
-    fun `should make regular request when requested`() {
-        mockWebServer.enqueue(MockResponse())
-
-        val accountManager: AccountManager = mock {
-            on { activeAccount } doAnswer { null }
-        }
-
-        val okHttpClient = OkHttpClient.Builder()
-            .addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
-            .build()
-
-        val request = Request.Builder()
-            .get()
-            .url(mockWebServer.url("/test"))
-            .build()
-
-        val response = okHttpClient.newCall(request).execute()
-
-        assertEquals(200, response.code)
-    }
-
-    @Test
-    fun `should make request to instance requested in special header`() {
-        mockWebServer.enqueue(MockResponse())
-
-        val accountManager: AccountManager = mock {
-            on { activeAccount } doAnswer {
-                AccountEntity(
-                    id = 1,
-                    domain = "test.domain",
-                    accessToken = "fakeToken",
-                    clientId = "fakeId",
-                    clientSecret = "fakeSecret",
-                    isActive = true
-                )
-            }
-        }
-
-        val okHttpClient = OkHttpClient.Builder()
-            .addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
-            .build()
-
-        val request = Request.Builder()
-            .get()
-            .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
-            .header(MastodonApi.DOMAIN_HEADER, mockWebServer.hostName)
-            .build()
-
-        val response = okHttpClient.newCall(request).execute()
-
-        assertEquals(200, response.code)
-
-        assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
-    }
-
-    @Test
-    fun `should make request to current instance when requested and user is logged in`() {
-        mockWebServer.enqueue(MockResponse())
-
-        val accountManager: AccountManager = mock {
-            on { activeAccount } doAnswer {
-                AccountEntity(
-                    id = 1,
-                    domain = mockWebServer.hostName,
-                    accessToken = "fakeToken",
-                    clientId = "fakeId",
-                    clientSecret = "fakeSecret",
-                    isActive = true
-                )
-            }
-        }
-
-        val okHttpClient = OkHttpClient.Builder()
-            .addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
-            .build()
-
-        val request = Request.Builder()
-            .get()
-            .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
-            .build()
-
-        val response = okHttpClient.newCall(request).execute()
-
-        assertEquals(200, response.code)
-
-        assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization"))
-    }
-
-    @Test
-    fun `should fail to make request when request to current instance is requested but no user is logged in`() {
-        mockWebServer.enqueue(MockResponse())
-
-        val accountManager: AccountManager = mock {
-            on { activeAccount } doAnswer { null }
-        }
-
-        val okHttpClient = OkHttpClient.Builder()
-            .addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
-            .build()
-
-        val request = Request.Builder()
-            .get()
-            .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + "/test")
-            .build()
-
-        val response = okHttpClient.newCall(request).execute()
-
-        assertEquals(400, response.code)
-        assertEquals(0, mockWebServer.requestCount)
-    }
-}
diff --git a/app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt b/app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt
new file mode 100644
index 0000000000..ae0024fa76
--- /dev/null
+++ b/app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt
@@ -0,0 +1,126 @@
+package com.keylesspalace.tusky.network
+
+import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
+import com.keylesspalace.tusky.db.entity.AccountEntity
+import com.keylesspalace.tusky.entity.Instance
+import com.squareup.moshi.Moshi
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+class RetrofitApiTest {
+
+    private val mockWebServer = MockWebServer()
+    private val okHttpClient = OkHttpClient.Builder().build()
+    private val moshi = Moshi.Builder().build()
+
+    @Before
+    fun setup() {
+        mockWebServer.start()
+    }
+
+    @After
+    fun teardown() {
+        mockWebServer.shutdown()
+    }
+
+    private fun retrofit() = Retrofit.Builder()
+        .baseUrl("http://${MastodonApi.PLACEHOLDER_DOMAIN}:${mockWebServer.port}")
+        .client(okHttpClient)
+        .addConverterFactory(MoshiConverterFactory.create(moshi))
+        .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
+        .build()
+
+    @Test
+    fun `should make request to the active account's instance`() = runTest {
+        mockInstanceResponse()
+
+        val account = AccountEntity(
+            id = 1,
+            domain = mockWebServer.hostName,
+            accessToken = "fakeToken",
+            clientId = "fakeId",
+            clientSecret = "fakeSecret",
+            isActive = true
+        )
+
+        val retrofit = retrofit()
+        val api: MastodonApi = apiForAccount(account, okHttpClient, retrofit, "http://", mockWebServer.port)
+
+        val instanceResponse = api.getInstance()
+
+        assertTrue(instanceResponse.isSuccess)
+        assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization"))
+    }
+
+    @Test
+    fun `should make request to instance requested in special header when account active`() = runTest {
+        mockInstanceResponse()
+
+        val account = AccountEntity(
+            id = 1,
+            domain = "test.domain",
+            accessToken = "fakeToken",
+            clientId = "fakeId",
+            clientSecret = "fakeSecret",
+            isActive = true
+        )
+
+        val retrofit = retrofit()
+        val api: MastodonApi = apiForAccount(account, okHttpClient, retrofit, "http://", mockWebServer.port)
+
+        val instanceResponse = api.getInstance(domain = mockWebServer.hostName)
+
+        assertTrue(instanceResponse.isSuccess)
+        assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
+    }
+
+    @Test
+    fun `should make request to instance requested in special header when no account active`() = runTest {
+        mockInstanceResponse()
+
+        val retrofit = retrofit()
+        val api: MastodonApi = apiForAccount(null, okHttpClient, retrofit, "http://", mockWebServer.port)
+
+        val instanceResponse = api.getInstance(domain = mockWebServer.hostName)
+
+        assertTrue(instanceResponse.isSuccess)
+        assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
+    }
+
+    @Test
+    fun `should fail when current instance is requested but no user is logged in`() = runTest {
+        mockInstanceResponse()
+
+        val retrofit = retrofit()
+        val api: MastodonApi = apiForAccount(null, okHttpClient, retrofit, "http://", mockWebServer.port)
+
+        val instanceResponse = api.getInstance()
+
+        assertTrue(instanceResponse.isFailure)
+        assertEquals(0, mockWebServer.requestCount)
+    }
+
+    private fun mockInstanceResponse() {
+        mockWebServer.enqueue(
+            MockResponse()
+                .setBody(
+                    moshi.adapter(Instance::class.java).toJson(
+                        Instance(
+                            domain = "example.org",
+                            version = "1.0.0"
+                        )
+                    )
+                )
+        )
+    }
+}