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

Old toots stuck on top of home timeline

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

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!

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

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()
-        }
-    }
-}

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"
+                        )
+                    )
+                )
+        )
+    }
+}


correctly count emojis when composing a post

Thx to @evant for the help

closes #4140

emoji character count

emoji character count
[Issue text goes here].

* * * *
- Tusky Version: 23
- Android Version: 13
- Android Device: Xiaomi note 10 pro
- Mastodon instance (if applicable): mastodon.uno

- [x ] I searched or browsed the repo’s other issues to ensure this is not a duplicate.

When I use an emoji inside a toot it is counted as if I inserted 3, in other apps it is counted as a single character

./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.components.compose.ComposeTokenizerTest" --tests "com.keylesspalace.tusky.components.compose.ComposeActivityTest" --tests "com.keylesspalace.tusky.components.compose.StatusLengthTest"

diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
index a87b60f587..0ee91e48f6 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
@@ -25,6 +25,7 @@ import android.content.pm.PackageManager
 import android.graphics.Bitmap
 import android.graphics.PorterDuff
 import android.graphics.PorterDuffColorFilter
+import android.icu.text.BreakIterator
 import android.net.Uri
 import android.os.Build
 import android.os.Bundle
@@ -1406,7 +1407,7 @@ class ComposeActivity :
          */
         @JvmStatic
         fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
-            var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
+            var length = body.toString().perceivedCharacterLength() - body.getSpans(0, body.length, URLSpan::class.java)
                 .fold(0) { acc, span ->
                     // Accumulate a count of characters to be *ignored* in the final length
                     acc + when (span) {
@@ -1419,15 +1420,25 @@ class ComposeActivity :
                         }
                         else -> {
                             // Expected to be negative if the URL length < maxUrlLength
-                            span.url.length - urlLength
+                            span.url.perceivedCharacterLength() - urlLength
                         }
                     }
                 }

             // Content warning text is treated as is, URLs or mentions there are not special
-            contentWarning?.let { length += it.length }
-
+            contentWarning?.let { length += it.toString().perceivedCharacterLength() }
             return length
         }
+
+        // String.length would count emojis as multiple characters but Mastodon counts them as 1, so we need this workaround
+        private fun String.perceivedCharacterLength(): Int {
+            val breakIterator = BreakIterator.getCharacterInstance()
+            breakIterator.setText(this)
+            var count = 0
+            while (breakIterator.next() != BreakIterator.DONE) {
+                count++
+            }
+            return count
+        }
     }
 }

diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt
similarity index 94%
rename from app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
rename to app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt
index 451b5c09a0..f46550e047 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt
@@ -15,7 +15,7 @@
  * see <http://www.gnu.org/licenses>.
  */

-package com.keylesspalace.tusky.components.compose.ComposeActivity
+package com.keylesspalace.tusky.components.compose

 import android.content.Intent
 import android.os.Looper.getMainLooper
@@ -24,8 +24,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import at.connyduck.calladapter.networkresult.NetworkResult
 import com.google.gson.Gson
 import com.keylesspalace.tusky.R
-import com.keylesspalace.tusky.components.compose.ComposeActivity
-import com.keylesspalace.tusky.components.compose.ComposeViewModel
 import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
 import com.keylesspalace.tusky.db.AccountEntity
 import com.keylesspalace.tusky.db.AccountManager
@@ -252,7 +250,21 @@ class ComposeActivityTest {
     fun whenTextContainsNoUrl_everyCharacterIsCounted() {
         val content = "This is test content please ignore thx "
         insertSomeTextInContent(content)
-        assertEquals(activity.calculateTextLength(), content.length)
+        assertEquals(content.length, activity.calculateTextLength())
+    }
+
+    @Test
+    fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() {
+        val content = "Test 😜"
+        insertSomeTextInContent(content)
+        assertEquals(6, activity.calculateTextLength())
+    }
+
+    @Test
+    fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() {
+        val content = "https://🤪.com"
+        insertSomeTextInContent(content)
+        assertEquals(InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
     }

     @Test
@@ -260,7 +272,7 @@ class ComposeActivityTest {
         val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
         val additionalContent = "Check out this @image #search result: "
         insertSomeTextInContent(additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL)
+        assertEquals(additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
     }

     @Test
@@ -269,7 +281,7 @@ class ComposeActivityTest {
         val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
         val additionalContent = " Check out this @image #search result: "
         insertSomeTextInContent(shortUrl + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
+        assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
     }

     @Test
@@ -277,7 +289,7 @@ class ComposeActivityTest {
         val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
         val additionalContent = " Check out this @image #search result: "
         insertSomeTextInContent(url + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
+        assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
     }

     @Test
@@ -289,7 +301,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + customUrlLength)
+        assertEquals(additionalContent.length + customUrlLength, activity.calculateTextLength())
     }

     @Test
@@ -301,7 +313,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + customUrlLength)
+        assertEquals(additionalContent.length + customUrlLength, activity.calculateTextLength())
     }

     @Test
@@ -314,7 +326,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(shortUrl + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }

     @Test
@@ -327,7 +339,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(shortUrl + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }

     @Test
@@ -339,7 +351,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(url + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }

     @Test
@@ -351,7 +363,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(url + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }

     @Test
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer/ComposeTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizerTest.kt
similarity index 96%
rename from app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer/ComposeTokenizerTest.kt
rename to app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizerTest.kt
index 04c692bce0..3c801dcbb2 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer/ComposeTokenizerTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizerTest.kt
@@ -13,9 +13,8 @@
  * 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.components.compose.ComposeTokenizer
+package com.keylesspalace.tusky.components.compose

-import com.keylesspalace.tusky.components.compose.ComposeTokenizer
 import org.junit.Assert
 import org.junit.Test
 import org.junit.runner.RunWith
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/StatusLengthTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/StatusLengthTest.kt
similarity index 90%
rename from app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/StatusLengthTest.kt
rename to app/src/test/java/com/keylesspalace/tusky/components/compose/StatusLengthTest.kt
index 81c40a556f..5f80963052 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/StatusLengthTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/StatusLengthTest.kt
@@ -15,29 +15,29 @@
  * see <http://www.gnu.org/licenses>.
  */

-package com.keylesspalace.tusky.components.compose.ComposeActivity
+package com.keylesspalace.tusky.components.compose

 import com.keylesspalace.tusky.SpanUtilsTest
-import com.keylesspalace.tusky.components.compose.ComposeActivity
 import com.keylesspalace.tusky.util.highlightSpans
 import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
+import org.robolectric.ParameterizedRobolectricTestRunner

-@RunWith(Parameterized::class)
+@RunWith(ParameterizedRobolectricTestRunner::class)
 class StatusLengthTest(
     private val text: String,
     private val expectedLength: Int
 ) {
     companion object {
-        @Parameterized.Parameters(name = "{0}")
+        @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
         @JvmStatic
         fun data(): Iterable<Any> {
             return listOf(
                 arrayOf("", 0),
                 arrayOf(" ", 1),
                 arrayOf("123", 3),
+                arrayOf("🫣", 1),
                 // "@user@server" should be treated as "@user"
                 arrayOf("123 @example@example.org", 12),
                 // URLs under 23 chars are treated as 23 chars


Fix deserialization of the response from friendica on api/v2/instance

Fixes #4100

[friendica] Post length limited to 500 in recent Friendica RC
Using Tusky Test 23.0-e9195846 with Friendica 2023.09-rc on loma.ml, I noticed that all of a sudden I can't compose posts longer than 500 chars anymore. Current Tusky stable does however display the "correct" amount of chars allowed per post (around 20k on that instance). Any way to fix this?

My instinct is that it's a regression caused by [instance v2 support](https://github.com/tuskyapp/Tusky/pull/4062), in particular I notice that https://loma.ml/api/v2/instance fails to return a number of fields in `configuration` that I understood to be "mandatory" from the mastodon api docs

I'll investigate

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 4031181ed2..c781ff90e3 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
@@ -64,17 +64,17 @@ class InstanceInfoRepository @Inject constructor(
                 { instance ->
                     val instanceEntity = InstanceInfoEntity(
                         instance = instanceName,
-                        maximumTootCharacters = instance.configuration.statuses.maxCharacters,
-                        maxPollOptions = instance.configuration.polls.maxOptions,
-                        maxPollOptionLength = instance.configuration.polls.maxCharactersPerOption,
-                        minPollDuration = instance.configuration.polls.minExpirationSeconds,
-                        maxPollDuration = instance.configuration.polls.maxExpirationSeconds,
-                        charactersReservedPerUrl = instance.configuration.statuses.charactersReservedPerUrl,
+                        maximumTootCharacters = instance.configuration.statuses?.maxCharacters ?: DEFAULT_CHARACTER_LIMIT,
+                        maxPollOptions = instance.configuration.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT,
+                        maxPollOptionLength = instance.configuration.polls?.maxCharactersPerOption ?: DEFAULT_MAX_OPTION_LENGTH,
+                        minPollDuration = instance.configuration.polls?.minExpirationSeconds ?: DEFAULT_MIN_POLL_DURATION,
+                        maxPollDuration = instance.configuration.polls?.maxExpirationSeconds ?: DEFAULT_MAX_POLL_DURATION,
+                        charactersReservedPerUrl = instance.configuration.statuses?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
                         version = instance.version,
-                        videoSizeLimit = instance.configuration.mediaAttachments.videoSizeLimitBytes.toInt(),
-                        imageSizeLimit = instance.configuration.mediaAttachments.imageSizeLimitBytes.toInt(),
-                        imageMatrixLimit = instance.configuration.mediaAttachments.imagePixelCountLimit.toInt(),
-                        maxMediaAttachments = instance.configuration.statuses.maxMediaAttachments,
+                        videoSizeLimit = instance.configuration.mediaAttachments?.videoSizeLimitBytes?.toInt() ?: DEFAULT_VIDEO_SIZE_LIMIT,
+                        imageSizeLimit = instance.configuration.mediaAttachments?.imageSizeLimitBytes?.toInt() ?: DEFAULT_IMAGE_SIZE_LIMIT,
+                        imageMatrixLimit = instance.configuration.mediaAttachments?.imagePixelCountLimit?.toInt() ?: DEFAULT_IMAGE_MATRIX_LIMIT,
+                        maxMediaAttachments = instance.configuration.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
                         maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
                         maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
                         maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength,
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt
index bf5aa2804d..048367535e 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt
@@ -31,12 +31,12 @@ data class Instance(
         )
     }
     data class Configuration(
-        val urls: Urls,
-        val accounts: Accounts,
-        val statuses: Statuses,
-        @SerializedName("media_attachments") val mediaAttachments: MediaAttachments,
-        val polls: Polls,
-        val translation: Translation,
+        val urls: Urls?,
+        val accounts: Accounts?,
+        val statuses: Statuses?,
+        @SerializedName("media_attachments") val mediaAttachments: MediaAttachments?,
+        val polls: Polls?,
+        val translation: Translation?,
     ) {
         data class Urls(@SerializedName("streaming_api") val streamingApi: String)
         data class Accounts(@SerializedName("max_featured_tags") val maxFeaturedTags: Int)
@@ -46,7 +46,8 @@ data class Instance(
             @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int,
         )
         data class MediaAttachments(
-            @SerializedName("supported_mime_types") val supportedMimeTypes: List<String>,
+            // Warning: This is an array in mastodon and a dictionary in friendica
+            // @SerializedName("supported_mime_types") val supportedMimeTypes: List<String>,
             @SerializedName("image_size_limit") val imageSizeLimitBytes: Long,
             @SerializedName("image_matrix_limit") val imagePixelCountLimit: Long,
             @SerializedName("video_size_limit") val videoSizeLimitBytes: Long,

diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
index ecff6d3be5..451b5c09a0 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
@@ -22,6 +22,7 @@ import android.os.Looper.getMainLooper
 import android.widget.EditText
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import at.connyduck.calladapter.networkresult.NetworkResult
+import com.google.gson.Gson
 import com.keylesspalace.tusky.R
 import com.keylesspalace.tusky.components.compose.ComposeActivity
 import com.keylesspalace.tusky.components.compose.ComposeViewModel
@@ -95,6 +96,7 @@ class ComposeActivityTest {
     private var instanceV1ResponseCallback: (() -> InstanceV1)? = null
     private var instanceResponseCallback: (() -> Instance)? = null
     private var composeOptions: ComposeActivity.ComposeOptions? = null
+    private val gson = Gson()

     @Before
     fun setupActivity() {
@@ -528,6 +530,15 @@ class ComposeActivityTest {
         assertEquals(language, activity.selectedLanguage)
     }

+    @Test
+    fun sampleFriendicaInstanceResponseIsDeserializable() {
+        // https://github.com/tuskyapp/Tusky/issues/4100
+        instanceResponseCallback = { getSampleFriendicaInstance() }
+        setupActivity()
+        shadowOf(getMainLooper()).idle()
+        assertEquals(friendicaMaximum, activity.maximumTootCharacters)
+    }
+
     private fun clickUp() {
         val menuItem = RoboMenuItem(android.R.id.home)
         activity.onOptionsItemSelected(menuItem)
@@ -560,7 +571,7 @@ class ComposeActivityTest {
                 InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS,
                 charactersReservedPerUrl ?: InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
             ),
-            Instance.Configuration.MediaAttachments(emptyList(), 0, 0, 0, 0, 0),
+            Instance.Configuration.MediaAttachments(0, 0, 0, 0, 0),
             Instance.Configuration.Polls(0, 0, 0, 0),
             Instance.Configuration.Translation(false),
         )
@@ -591,4 +602,83 @@ class ComposeActivityTest {
             polls = null
         )
     }
+
+    private fun getSampleFriendicaInstance(): Instance {
+        return gson.fromJson(sampleFriendicaResponse, Instance::class.java)
+    }
+
+    companion object {
+        private const val friendicaMaximum = 200000
+
+        // https://github.com/tuskyapp/Tusky/issues/4100
+        private val sampleFriendicaResponse = """{
+                "domain": "loma.ml",
+                "title": "[ˈloma]",
+                "version": "2.8.0 (compatible; Friendica 2023.09-rc)",
+                "source_url": "https://git.friendi.ca/friendica/friendica",
+                "description": "loma.ml ist eine Friendica Community im Fediverse auf der vorwiegend DE \uD83C\uDDE9\uD83C\uDDEA gesprochen wird. \\r\\nServer in Germany/EU \uD83C\uDDE9\uD83C\uDDEA \uD83C\uDDEA\uD83C\uDDFA. Open to all with fun in new. \\r\\nServer in Deutschland. Offen für alle mit Spaß an Neuen.",
+                "usage": {
+                    "users": {
+                        "active_month": 125
+                    }
+                },
+                "thumbnail": {
+                    "url": "https://loma.ml/ad/friendica-banner.jpg"
+                },
+                "languages": [
+                    "de"
+                ],
+                "configuration": {
+                    "statuses": {
+                        "max_characters": $friendicaMaximum
+                    },
+                    "media_attachments": {
+                        "supported_mime_types": {
+                            "image/jpeg": "jpg",
+                            "image/jpg": "jpg",
+                            "image/png": "png",
+                            "image/gif": "gif"
+                        },
+                        "image_size_limit": 10485760
+                    }
+                },
+                "registrations": {
+                    "enabled": true,
+                    "approval_required": false
+                },
+                "contact": {
+                    "email": "anony@miz.ed",
+                    "account": {
+                        "id": "9632",
+                        "username": "webm",
+                        "acct": "webm",
+                        "display_name": "web m \uD83C\uDDEA\uD83C\uDDFA",
+                        "locked": false,
+                        "bot": false,
+                        "discoverable": true,
+                        "group": false,
+                        "created_at": "2018-05-21T11:24:55.000Z",
+                        "note": "\uD83C\uDDE9\uD83C\uDDEA Über diesen Account werden Änderungen oder geplante Beeinträchtigungen angekündigt. Wenn du einen Account auf Loma.ml besitzt, dann solltest du dich mit mir verbinden.\uD83C\uDDEA\uD83C\uDDFA Changes or planned impairments are announced via this account. If you have an account on Loma.ml, you should connect to me.\uD83C\uDD98 Fallbackaccount @webm@joinfriendica.de",
+                        "url": "https://loma.ml/profile/webm",
+                        "avatar": "https://loma.ml/photo/contact/320/373ebf56355ac895a09cb99264485383?ts=1686417730",
+                        "avatar_static": "https://loma.ml/photo/contact/320/373ebf56355ac895a09cb99264485383?ts=1686417730&static=1",
+                        "header": "https://loma.ml/photo/header/373ebf56355ac895a09cb99264485383?ts=1686417730",
+                        "header_static": "https://loma.ml/photo/header/373ebf56355ac895a09cb99264485383?ts=1686417730&static=1",
+                        "followers_count": 23,
+                        "following_count": 25,
+                        "statuses_count": 15,
+                        "last_status_at": "2023-09-19T00:00:00.000Z",
+                        "emojis": [],
+                        "fields": []
+                    }
+                },
+                "rules": [],
+                "friendica": {
+                    "version": "2023.09-rc",
+                    "codename": "Giant Rhubarb",
+                    "db_version": 1539
+                }
+            }
+        """.trimIndent()
+    }
 }


Show better errors when loading notifications fails

The errors are returned as a JSON object, parse it, and show the error message it contains.

Handle the cases where there might be no error message, or the JSON may be malformed.

Add tests.

Fixes #3445

Error message handling for notifications still needs work
https://github.com/tuskyapp/Tusky/pull/3440 displayed error text when notifications fail to load because of the server, but the presentation of that can still be improved. I caught an error from mastodon.social a moment ago, and it currently looks like this:

<img src="https://user-images.githubusercontent.com/773100/224696114-f41bcd30-fcb2-42ca-9ed4-3ca1487fb1d4.png" width="60%">

That's obviously a JSON representation of the error. That's lead me to https://docs.joinmastodon.org/entities/Error/, so the right thing to do here is probably to decode this and present the error to user. Probably as:

```
<http error code>: <error>

<error description>
```

I want to avoid hiding this info behind a generic "An error occurred" message because it makes it impossible to troubleshoot user reports of problems.

diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt
index 90d24fed94..5f7eafb0ef 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt
@@ -20,6 +20,7 @@ package com.keylesspalace.tusky.components.notifications
 import android.util.Log
 import androidx.paging.PagingSource
 import androidx.paging.PagingState
+import com.google.gson.Gson
 import com.keylesspalace.tusky.entity.Notification
 import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.util.HttpHeaderLink
@@ -35,6 +36,7 @@ data class Links(val next: String?, val prev: String?)
 /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
 class NotificationsPagingSource @Inject constructor(
     private val mastodonApi: MastodonApi,
+    private val gson: Gson,
     private val notificationFilter: Set<Notification.Type>
 ) : PagingSource<String, Notification>() {
     override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
@@ -58,7 +60,23 @@ class NotificationsPagingSource @Inject constructor(
             }

             if (!response.isSuccessful) {
-                return LoadResult.Error(Throwable(response.errorBody()?.string()))
+                val code = response.code()
+
+                val msg = response.errorBody()?.string()?.let { errorBody ->
+                    if (errorBody.isBlank()) return@let "no reason given"
+
+                    val error = try {
+                        gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java)
+                    } catch (e: Exception) {
+                        return@let "$errorBody ($e)"
+                    }
+
+                    when (val desc = error.error_description) {
+                        null -> error.error
+                        else -> "${error.error}: $desc"
+                    }
+                } ?: "no reason given"
+                return LoadResult.Error(Throwable("HTTP $code: $msg"))
             }

             val links = getPageLinks(response.headers()["link"])
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt
index 25c8458ace..b933a9a3f0 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt
@@ -23,6 +23,7 @@ import androidx.paging.Pager
 import androidx.paging.PagingConfig
 import androidx.paging.PagingData
 import androidx.paging.PagingSource
+import com.google.gson.Gson
 import com.keylesspalace.tusky.entity.Notification
 import com.keylesspalace.tusky.network.MastodonApi
 import kotlinx.coroutines.flow.Flow
@@ -31,7 +32,8 @@ import retrofit2.Response
 import javax.inject.Inject

 class NotificationsRepository @Inject constructor(
-    private val mastodonApi: MastodonApi
+    private val mastodonApi: MastodonApi,
+    private val gson: Gson
 ) {
     private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null

@@ -47,7 +49,7 @@ class NotificationsRepository @Inject constructor(
         Log.d(TAG, "getNotificationsStream(), filtering: $filter")

         factory = InvalidatingPagingSourceFactory {
-            NotificationsPagingSource(mastodonApi, filter)
+            NotificationsPagingSource(mastodonApi, gson, filter)
         }

         return Pager(
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt
new file mode 100644
index 0000000000..f78cafacdc
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2023 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.entity
+
+/** @see [Error](https://docs.joinmastodon.org/entities/Error/) */
+data class Error(
+    val error: String,
+    val error_description: String?
+)

diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt
new file mode 100644
index 0000000000..450f702a7c
--- /dev/null
+++ b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2023 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.components.notifications
+
+import androidx.paging.PagingSource
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.gson.Gson
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.network.MastodonApi
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.robolectric.annotation.Config
+import retrofit2.Response
+
+@Config(sdk = [28])
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsPagingSourceTest {
+    @Test
+    fun `load() returns error message on HTTP error`() = runTest {
+        // Given
+        val jsonError = "{error: 'This is an error'}".toResponseBody()
+        val mockApi: MastodonApi = mock {
+            onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
+            onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
+        }
+
+        val filter = emptySet<Notification.Type>()
+        val gson = Gson()
+        val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
+        val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
+
+        // When
+        val loadResult = pagingSource.load(loadingParams)
+
+        // Then
+        assertTrue(loadResult is PagingSource.LoadResult.Error)
+        assertEquals(
+            "HTTP 429: This is an error",
+            (loadResult as PagingSource.LoadResult.Error).throwable.message
+        )
+    }
+
+    // As previous, but with `error_description` field as well.
+    @Test
+    fun `load() returns extended error message on HTTP error`() = runTest {
+        // Given
+        val jsonError = "{error: 'This is an error', error_description: 'Description of the error'}".toResponseBody()
+        val mockApi: MastodonApi = mock {
+            onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
+            onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
+        }
+
+        val filter = emptySet<Notification.Type>()
+        val gson = Gson()
+        val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
+        val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
+
+        // When
+        val loadResult = pagingSource.load(loadingParams)
+
+        // Then
+        assertTrue(loadResult is PagingSource.LoadResult.Error)
+        assertEquals(
+            "HTTP 429: This is an error: Description of the error",
+            (loadResult as PagingSource.LoadResult.Error).throwable.message
+        )
+    }
+
+    // As previous, but no error JSON, so expect default response
+    @Test
+    fun `load() returns default error message on empty HTTP error`() = runTest {
+        // Given
+        val jsonError = "{}".toResponseBody()
+        val mockApi: MastodonApi = mock {
+            onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
+            onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
+        }
+
+        val filter = emptySet<Notification.Type>()
+        val gson = Gson()
+        val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
+        val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
+
+        // When
+        val loadResult = pagingSource.load(loadingParams)
+
+        // Then
+        assertTrue(loadResult is PagingSource.LoadResult.Error)
+        assertEquals(
+            "HTTP 429: no reason given",
+            (loadResult as PagingSource.LoadResult.Error).throwable.message
+        )
+    }
+
+    // As previous, but malformed JSON, so expect response with enough information to troubleshoot
+    @Test
+    fun `load() returns useful error message on malformed HTTP error`() = runTest {
+        // Given
+        val jsonError = "{'malformedjson}".toResponseBody()
+        val mockApi: MastodonApi = mock {
+            onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
+            onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
+        }
+
+        val filter = emptySet<Notification.Type>()
+        val gson = Gson()
+        val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
+        val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
+
+        // When
+        val loadResult = pagingSource.load(loadingParams)
+
+        // Then
+        assertTrue(loadResult is PagingSource.LoadResult.Error)
+        assertEquals(
+            "HTTP 429: {'malformedjson} (com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Unterminated string at line 1 column 17 path \$.)",
+            (loadResult as PagingSource.LoadResult.Error).throwable.message
+        )
+    }
+}


When looking up fediverse urls, verify that account results returned match the input query

Fixes #2804

Link lookup ends up with wrong link

Link lookup ends up with wrong link
I have been using Tusky for a good week now and noticed that every now and then clicking on links in posts take me to the wrong internet address.

E.g.:
1. Click a link in a post, which should go to www.patrion.com/…
2. Land on the Mastodon profile of the European Commision?
A bit like chat roulette I guess :-)

Here's a link to a raw video: https://cloud.zell-mbc.com/s/QbQ2xFzY2TajKgZ
Most links work, some don't.

Edit: Forgot to mention that when I click that same link from a different client (browser, FediLab, etc.) the correct page opens.

* * * *
- Tusky Version: 19.0
- Android Version: 10
- Android Device: Stoneage Samsung tablet, SM-T830
- Mastodon instance (if applicable): Doesn't matter

- [x ] I searched or browsed the repo’s other issues to ensure this is not a duplicate.

Is the account of the European commission involved or mentioned in this thread? Does it happen only in thread view or also in other places?
Also, which instance are you on?
> Is the account of the European commission involved or mentioned in this thread? Does it happen only in thread view or also in other places?

Need to lookup this thread again to confirm, but would be surprised because it's been about a completely different subject.

> Does it happen only in thread view or also in other places?

Don't know either, will test next time this happens

> Also, which instance are you on?

My personal instance, Pleroma based

I have now installed Tusky on my mobile phone as well, so I can compare when this happens next time.

What happens if you throw the link into the search of your instance? Thats what Tusky is doing do determine if it is a fedi link or not
> What happens if you throw the link into the search of your instance?

You mean the search at the top of the Tusky screen, the magnifier? If so, nothing is found, which is correct (?) because it's a URL and not a post/account/hashtag
![image](https://user-images.githubusercontent.com/29018767/201396559-b38fcc4e-ebde-406c-b704-b3cf2c2be6e8.png)

> Does it happen only in thread view or also in other places?

It's happening on the main timeline  as well as the thread view

> Is the account of the European commission involved or mentioned in this thread?

No

> Tusky on my mobile phone

Shows the same behaviour
OnePlus 8 Pro, Android 12

And as far as my instance as potential reason for the failure is concerned, I am running [Fedilab](https://fedilab.app/) on that device as well, and there the link in that post works fine.

Yes the post in question and the link are fine, that is definitely not the problem.
I can think of two things that could cause such behavior
1) search returns wrong results when looking up the link
2) View recycling is wrongly implemented and the click listener of another link is invoked

But it looks like it is neither of those 🤔
I'll "click a bit more" going forward, maybe I can identify a pattern.
I know I had another post with the same issue but can't remember where this one took me to...
I got another one, same effect. I klick on a link and end up with someone's profile. I can provide a video if it helps.
https://cloud.zell-mbc.com/s/fBkTnfnXDnwJaPM
This happens to me frequently as well (server runs Akkoma.)

It seems to usually go to a link that has similar words in it?

![An example of the error happening](https://user-images.githubusercontent.com/82258270/218444063-2cfb9f9d-b890-4725-b81e-0888906cc877.gif)

o_0 Which post, which server is this?
> o_0 Which post, which server is this?

I posted a link to an archived version of someone's blog post which was 404'ing (https://archive.is/x9nzZ) onto my sever (moth.zone) and Tusky decided to send me to @anarchist_archive@kolektiva.social (https://kolektiva.social/@anarchist_archive) for some reason. Aside from both having the word "archive" there's no connection to either account. No clue why it happens, but it happens with a lot of other URLs too.
Ok, so it is case 1) from above. Search on some non-Mastodon servers returns accounts when searching for urls and Tusky opens the first one. I can reproduce it with one of my test accounts.
The solution is to check if the url of the found accounts matches the search before opening it. The relevant code is on BottomSheetActivity if someone wants to fix it.

./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.BottomSheetActivityTest"

diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
index 60d1966d1c..8df0c66171 100644
--- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
@@ -86,8 +86,11 @@ abstract class BottomSheetActivity : BaseActivity() {
                     if (statuses.isNotEmpty()) {
                         viewThread(statuses[0].id, statuses[0].url)
                         return@subscribe
-                    } else if (accounts.isNotEmpty()) {
-                        viewAccount(accounts[0].id)
+                    }
+                    accounts.firstOrNull { it.url == url }?.let { account ->
+                        // Some servers return (unrelated) accounts for url searches (#2804)
+                        // Verify that the account's url matches the query
+                        viewAccount(account.id)
                         return@subscribe
                     }

diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
index 66b922bd5a..f4b12a61f0 100644
--- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
@@ -46,6 +46,7 @@ class BottomSheetActivityTest {
     private lateinit var apiMock: MastodonApi
     private val accountQuery = "http://mastodon.foo.bar/@User"
     private val statusQuery = "http://mastodon.foo.bar/@User/345678"
+    private val nonexistentStatusQuery = "http://mastodon.foo.bar/@User/345678000"
     private val nonMastodonQuery = "http://medium.com/@correspondent/345678"
     private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList()))
     private val testScheduler = TestScheduler()
@@ -55,7 +56,7 @@ class BottomSheetActivityTest {
         localUsername = "admin",
         username = "admin",
         displayName = "Ad Min",
-        url = "http://mastodon.foo.bar",
+        url = "http://mastodon.foo.bar/@User",
         avatar = ""
     )
     private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList()))
@@ -101,6 +102,7 @@ class BottomSheetActivityTest {
         apiMock = mock {
             on { searchObservable(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle
             on { searchObservable(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusSingle
+            on { searchObservable(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle
             on { searchObservable(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyCallback
         }

@@ -184,6 +186,14 @@ class BottomSheetActivityTest {
         }
     }

+    @Test
+    fun search_doesNotRespectUnrelatedResult() {
+        activity.viewUrl(nonexistentStatusQuery)
+        testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
+        assertEquals(nonexistentStatusQuery, activity.link)
+        assertEquals(null, activity.accountId)
+    }
+
     @Test
     fun search_withCancellation_doesNotLoadUrl_forAccount() {
         activity.viewUrl(accountQuery)