prevent mixup of account timelines (#4599)

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
This commit is contained in:
Konrad Pozniak 2024-08-14 18:58:12 +02:00 committed by GitHub
commit c7387c7b52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 306 additions and 261 deletions

View file

@ -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
}
}

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

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

View file

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