Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ class BrowserLoginActivity : BaseActivity() {
logger.e(TAG, "Post login step failed")
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show()
}
BrowserLoginActivityViewModel.PostLoginViewState.PostLoginTooManyLoginAttempts -> {
logger.e(TAG, "Login refused because of too many failed logins")
Snackbar.make(binding.root, R.string.nc_login_too_many_attempts, Snackbar.LENGTH_LONG)
.show()
}
BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRestartApp -> {
restartApp()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin
val TAG: String = LoginRepository::class.java.simpleName
private const val INTERVAL = 250L
private const val HTTP_OK = 200

/** The status of a [LoginCompletion] for a login the server refused because of too many failed logins. */
const val HTTP_TOO_MANY_REQUESTS = 429
private const val USER_KEY = "user:"
private const val SERVER_KEY = "server:"
private const val PASS_KEY = "password:"
Expand Down Expand Up @@ -171,7 +174,12 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin

// Need to use the qr code token to create temporary credentials to get access to the actual app password
val credentials = Credentials.basic(loginName, appPassword)
val oneTimePassword = network.oneTimePasswordRequest(server, credentials)
val oneTimePassword = try {
network.oneTimePasswordRequest(server, credentials)
} catch (e: NetworkLoginDataSource.TooManyLoginAttemptsException) {
Log.w(TAG, "Server refused the one-time login because of too many failed logins", e)
return@withContext LoginCompletion(HTTP_TOO_MANY_REQUESTS, server, loginName, "")
}

return@withContext if (server.isNotEmpty() && loginName.isNotEmpty() && oneTimePassword != null) {
LoginCompletion(HTTP_OK, server, loginName, oneTimePassword)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,19 @@ class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {

companion object {
val TAG: String = NetworkLoginDataSource::class.java.simpleName
private const val HTTP_TOO_MANY_REQUESTS = 429
}

/**
* The server refused a login because of too many failed logins from this IP, without checking the credentials.
*/
class TooManyLoginAttemptsException(message: String) : IOException(message)

/**
* Exchanges the one-time password of [oneTimeCredentials] for an app password, or returns null if that failed.
*
* @throws TooManyLoginAttemptsException if the server refused the login, the one-time password is still unused then
*/
fun oneTimePasswordRequest(baseUrl: String, oneTimeCredentials: String): String? {
val url = "$baseUrl/ocs/v2.php/core/getapppassword-onetime"
var result: String? = null
Expand All @@ -42,6 +53,8 @@ class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {
result = appPassword
}.getOrElse { e ->
when (e) {
is TooManyLoginAttemptsException -> throw e

is SSLHandshakeException,
is NullPointerException,
is IOException -> {
Expand All @@ -66,6 +79,9 @@ class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {

val newOkHttpClient = OkHttpClient()
newOkHttpClient.newCall(request).execute().use { response ->
if (response.code == HTTP_TOO_MANY_REQUESTS) {
throw TooManyLoginAttemptsException("Too many failed logins from this IP: $response")
}
if (!response.isSuccessful) {
throw IOException("Unexpected code $response")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nextcloud.talk.account.data.LoginRepository
import com.nextcloud.talk.account.data.model.LoginCompletion
import com.nextcloud.talk.account.data.model.LoginResponse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -37,6 +38,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep
data object None : PostLoginViewState()
data object PostLoginRestartApp : PostLoginViewState()
data object PostLoginError : PostLoginViewState()
data object PostLoginTooManyLoginAttempts : PostLoginViewState()
data class PostLoginContinue(val data: Bundle) : PostLoginViewState()
data object PostLoginDifferentAccount : PostLoginViewState()
}
Expand Down Expand Up @@ -83,44 +85,37 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep
fun handleWebBrowserLogin() {
savedResponse?.let { response ->
viewModelScope.launch {
val loginCompletionResponse = repository.pollLogin(response)

if (loginCompletionResponse == null) {
_postLoginState.value = PostLoginViewState.PostLoginError
return@launch
}

_postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse))
_postLoginState.value = postLoginStateFor(repository.pollLogin(response))
}
}
}

fun loginWithQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) {
if (!startLoginOnce()) return
viewModelScope.launch {
val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth, accountToReauthorize)
if (loginCompletionResponse == null) {
_postLoginState.value = PostLoginViewState.PostLoginError
return@launch
}

_postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse))
_postLoginState.value =
postLoginStateFor(repository.startLoginFlowFromQR(dataString, reAuth, accountToReauthorize))
}
}

fun loginWithOTPQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) {
if (!startLoginOnce()) return
viewModelScope.launch {
val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth, accountToReauthorize)
if (loginCompletionResponse == null) {
_postLoginState.value = PostLoginViewState.PostLoginError
return@launch
}

_postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse))
_postLoginState.value =
postLoginStateFor(repository.startOTPLoginFlow(dataString, reAuth, accountToReauthorize))
}
}

private suspend fun postLoginStateFor(loginCompletion: LoginCompletion?): PostLoginViewState =
when {
loginCompletion == null -> PostLoginViewState.PostLoginError

loginCompletion.status == LoginRepository.HTTP_TOO_MANY_REQUESTS ->
PostLoginViewState.PostLoginTooManyLoginAttempts

else -> postLoginStateFor(repository.parseAndLogin(loginCompletion))
}

private fun postLoginStateFor(result: LoginRepository.LoginResult): PostLoginViewState =
when (result) {
is LoginRepository.LoginResult.NewAccount -> PostLoginViewState.PostLoginContinue(result.bundle)
Expand Down
10 changes: 0 additions & 10 deletions app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.webkit.SslErrorHandler
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
Expand All @@ -41,7 +40,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.events.CertificateEvent
import com.nextcloud.talk.events.RemoteWipeEvent
import com.nextcloud.talk.lock.LockedActivity
import com.nextcloud.talk.users.DefaultAccountProvider
import com.nextcloud.talk.users.UserManager
Expand Down Expand Up @@ -402,14 +400,6 @@ open class BaseActivity : AppCompatActivity() {
showCertificateDialog(event.x509Certificate, event.trustManager, event.sslErrorHandler)
}

@Subscribe(threadMode = ThreadMode.MAIN)
fun onRemoteWipeEvent(event: RemoteWipeEvent) {
Toast.makeText(context, R.string.nc_remote_wipe_logged_out, Toast.LENGTH_LONG).show()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
}

override fun startActivity(intent: Intent) {
// Links to the own server are opened for the account of this screen. Screens without an account (boundUser
// never loaded) use the default account, without resolving one from their intent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.participants.ParticipantDto
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.message.SendMessageUtils
import com.nextcloud.talk.utils.revertOnCancellation
import com.nextcloud.talk.utils.withRetry
Expand All @@ -42,6 +41,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
Expand All @@ -52,9 +52,11 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
import kotlin.math.min

@Suppress("LargeClass", "TooManyFunctions")
class OfflineFirstChatRepository @Inject constructor(
Expand Down Expand Up @@ -303,18 +305,25 @@ class OfflineFirstChatRepository @Inject constructor(
lastKnown = initialMessageId.toInt()
)

val networkParams = Bundle()
var failureDelayMillis = LONG_POLLING_FAILURE_INITIAL_DELAY

while (true) {
if (!networkMonitor.isOnline.value || itIsPaused) {
failureDelayMillis = LONG_POLLING_FAILURE_INITIAL_DELAY
delay(HALF_SECOND)
} else {
// sync database with server
// (This is a long blocking call because long polling (lookIntoFuture and timeout) is set)
networkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)

Log.d(TAG, "Starting online request for long polling")
getAndPersistMessages(networkParams)
val outcome = syncer.pullAndPersistMessages(syncTarget, fieldMap, syncEvents)

// A failed request returns right away, e.g. with rejected credentials, which the server counts as a
// failed login. Without waiting, the next requests would follow at once until the server throttles.
failureDelayMillis = if (outcome.syncFailed) {
waitAfterFailedRequest(failureDelayMillis)
} else {
LONG_POLLING_FAILURE_INITIAL_DELAY
}

val newestMessage = chatBlocksDao.getNewestMessageIdFromChatBlocks(
internalConversationId,
Expand All @@ -332,6 +341,24 @@ class OfflineFirstChatRepository @Inject constructor(
}
}

/**
* Waits [delayMillis] after a failed long polling request, and returns how long to wait if the next one fails too.
* Requests that failed while the device went offline should not delay the next one, so the wait ends early once
* it is online again.
*/
private suspend fun waitAfterFailedRequest(delayMillis: Long): Long {
Log.d(TAG, "Long polling failed, next request in $delayMillis ms")
val gotOnlineAgain = withTimeoutOrNull(delayMillis) {
networkMonitor.isOnline.drop(1).first { isOnline -> isOnline }
} != null

return if (gotOnlineAgain) {
LONG_POLLING_FAILURE_INITIAL_DELAY
} else {
min(delayMillis * 2, LONG_POLLING_FAILURE_MAX_DELAY)
}
}

suspend fun initInsuranceRequests() {
Log.d(TAG, "---- initInsuranceRequests ------------")

Expand Down Expand Up @@ -563,16 +590,6 @@ class OfflineFirstChatRepository @Inject constructor(
}
}

// Callers must put a KEY_FIELD_MAP (see getFieldMap/syncer.buildFieldMap) into bundle before
// calling this.
private suspend fun getAndPersistMessages(bundle: Bundle): Boolean {
val fieldMap = requireNotNull(bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as? HashMap<String, Int>) {
"getAndPersistMessages requires bundle to carry KEY_FIELD_MAP"
}
val outcome = syncer.pullAndPersistMessages(syncTarget, fieldMap, syncEvents)
return outcome.persistedNewMessages
}

private fun isUntranslatedSystemMessage(messagesJson: List<ChatMessageDto>): Boolean =
syncer.isUntranslatedSystemMessage(messagesJson)

Expand Down Expand Up @@ -1273,6 +1290,8 @@ class OfflineFirstChatRepository @Inject constructor(
companion object {
val TAG: String = OfflineFirstChatRepository::class.java.simpleName
private const val HALF_SECOND = 500L
private const val LONG_POLLING_FAILURE_INITIAL_DELAY = 1_000L
private const val LONG_POLLING_FAILURE_MAX_DELAY = 60_000L
private const val DEFAULT_MESSAGES_LIMIT = 100
private const val MILLIES = 1000L
private const val INSURANCE_REQUEST_DELAY = 2 * 60 * MILLIES
Expand Down
Loading
Loading