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 @@ -49,6 +49,8 @@ class BrowserLoginActivity : BaseActivity() {
private var reauthorizeAccount = false
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Leaving the login screen abandons the login, it must not be resumed when the app is opened again.
viewModel.cancelLogin()
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
Expand Down Expand Up @@ -101,6 +103,7 @@ class BrowserLoginActivity : BaseActivity() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.postLoginState.collect { state ->
viewModel.onPostLoginShown(state)
when (state) {
BrowserLoginActivityViewModel.PostLoginViewState.None -> {}

Expand Down Expand Up @@ -144,6 +147,9 @@ class BrowserLoginActivity : BaseActivity() {
// The account to reauthorize, so a login with another account doesn't change it.
val accountToReauthorize = extras.getLong(BundleKeys.KEY_INTERNAL_USER_ID, 0L).takeIf { it != 0L }

// If the saved login can't be continued, it is started again below with the same extras.
if (extras.getBoolean(BundleKeys.KEY_RESUME_BROWSER_LOGIN, false) && viewModel.resumeWebBrowserLogin()) return

if (extras.containsKey(BundleKeys.KEY_FROM_QR)) {
val uri = extras.getString(BundleKeys.KEY_FROM_QR)!!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,27 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin
private var accountToReauthorize: Long? = null
private var shouldLoop = true

suspend fun pollLogin(response: LoginResponse): LoginCompletion? =
/**
* Polls until the login is confirmed, the login is canceled, or [keepPolling] is false (checked after every pause).
*
* The server hands out the login data only once, and the blocking request is not interrupted when the caller is
* canceled. [onConfirmed] is therefore called with the data as soon as it arrived, before the cancellation of
* the caller can discard it.
*/
suspend fun pollLogin(
response: LoginResponse,
onConfirmed: (LoginCompletion) -> Unit = {},
keepPolling: () -> Boolean = { true }
): LoginCompletion? =
withContext(Dispatchers.IO) {
while (shouldLoop) {
while (shouldLoop && keepPolling()) {
val loginData = network.performLoginFlowV2(response)
if (loginData == null) {
break
}

if (loginData.status == HTTP_OK) {
onConfirmed(loginData)
return@withContext loginData
}

Expand Down Expand Up @@ -203,6 +215,14 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin
return@withContext response
}

/**
* Continues a login that was already started, the next step is [pollLogin] with its response.
*/
fun resumeLoginFlow(reAuth: Boolean, accountToReauthorize: Long?) {
shouldReauthorizeUser = reAuth
this.accountToReauthorize = accountToReauthorize
}

/**
* Ends normal login process by canceling the polling
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.account.data

import com.nextcloud.talk.account.data.model.LoginCompletion
import com.nextcloud.talk.account.data.model.LoginResponse

/**
* A login via the browser (login flow v2) that was started but not finished yet.
*/
data class PendingBrowserLogin(
val response: LoginResponse,
val baseUrl: String,
val reAuth: Boolean,
val accountToReauthorize: Long?,
val startedAtMillis: Long,
/** The confirmed login. The server hands it out once, so it is kept until the login screen has shown it. */
val completion: LoginCompletion? = null,
val completedAtMillis: Long = 0L
)

/**
* Keeps the unfinished browser login in memory only (the response holds the login token, so it must not be written to
* preferences or the database). It outlives [com.nextcloud.talk.account.BrowserLoginActivity], which the system
* destroys when the app is opened again by the launcher icon while the browser is in front. The login is then
* continued by polling the saved response, instead of being lost.
*
* The server hands out the confirmed login only once. It is saved here as soon as it arrives, and the login stays
* pending until the login screen has shown the result, so a screen destroyed before that doesn't lose it.
*/
object PendingBrowserLoginStore {
private fun PendingBrowserLogin.lastChangeMillis() = if (completion == null) startedAtMillis else completedAtMillis

private fun PendingBrowserLogin.lifetimeMillis() =
if (completion == null) LIFETIME_MILLIS else COMPLETED_LIFETIME_MILLIS

/** The login flow v2 lives 20 minutes on the server. */
const val LIFETIME_MILLIS = 20 * 60 * 1000L

/**
* How long a confirmed login is kept for a screen that did not show it yet. The server deleted its token already,
* so the 20 minutes of the flow don't apply anymore. Counted from the confirmation.
*/
const val COMPLETED_LIFETIME_MILLIS = 60 * 60 * 1000L

@Volatile
var clock: () -> Long = System::currentTimeMillis

// The callers are the main thread (save, cancel, show) and the polling on the IO dispatcher (complete), and each
// of them reads and then writes, so every access is synchronized.
private var pending: PendingBrowserLogin? = null

@Synchronized
fun save(response: LoginResponse, baseUrl: String, reAuth: Boolean, accountToReauthorize: Long?) {
pending = PendingBrowserLogin(response, baseUrl, reAuth, accountToReauthorize, clock())
}

/** The unfinished login, or null if there is none or it expired. */
@Synchronized
fun active(): PendingBrowserLogin? {
val current = pending
val isExpired = current != null && clock() - current.lastChangeMillis() >= current.lifetimeMillis()
if (isExpired) pending = null
return current.takeUnless { isExpired }
}

/** Keeps the confirmed login of [response] for a new view model. Ignored if that login is not pending anymore. */
@Synchronized
fun complete(response: LoginResponse, completion: LoginCompletion) {
val current = pending
if (current != null && current.response == response) {
pending = current.copy(completion = completion, completedAtMillis = clock())
}
}

/** Ends the pending login of [response]. A newer login of another response is kept. */
@Synchronized
fun clearIf(response: LoginResponse) {
if (pending?.response == response) pending = null
}

@Synchronized
fun clear() {
pending = null
}
}
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.PendingBrowserLoginStore
import com.nextcloud.talk.account.data.model.LoginCompletion
import com.nextcloud.talk.account.data.model.LoginResponse
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -60,6 +61,10 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep
// death the view model is new, so the login is started again.
private var isLoginStarted = false

// A login request or poll still in flight when the login is canceled must not save its response, which a later
// return via the launcher icon would resume, nor complete the login.
private var isLoginCanceled = false

private fun startLoginOnce(): Boolean {
if (isLoginStarted) return false
isLoginStarted = true
Expand All @@ -70,26 +75,70 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep
if (!startLoginOnce()) return
viewModelScope.launch {
val response = repository.startLoginFlow(baseUrl, reAuth, accountToReauthorize)
if (isLoginCanceled) return@launch
savedResponse = response

if (response == null) {
_initialLoginRequestState.value = InitialLoginViewState.InitialLoginRequestError
return@launch
}

// Survives this view model, see PendingBrowserLoginStore.
PendingBrowserLoginStore.save(response, baseUrl, reAuth, accountToReauthorize)

_initialLoginRequestState.value =
InitialLoginViewState.InitialLoginRequestSuccess(response.loginUrl)
}
}

/**
* Continues the browser login that a destroyed [com.nextcloud.talk.account.BrowserLoginActivity] left unfinished:
* no new login request and no new browser, only the polling of the saved response.
*
* Returns false if there is nothing to continue (expired, or lost by process death), then the login has to be
* started again.
*/
fun resumeWebBrowserLogin(): Boolean {
if (!isLoginStarted) {
val pending = PendingBrowserLoginStore.active() ?: return false
startLoginOnce()
savedResponse = pending.response
repository.resumeLoginFlow(pending.reAuth, pending.accountToReauthorize)
handleWebBrowserLogin()
}
return true
}

fun handleWebBrowserLogin() {
savedResponse?.let { response ->
viewModelScope.launch {
_postLoginState.value = postLoginStateFor(repository.pollLogin(response))
// A login confirmed for an earlier view model is not polled again: the server hands it out once.
val polled = PendingBrowserLoginStore.active()?.completion ?: pollConfirmedLogin(response)
// A login canceled while its poll was in flight is not completed.
if (isLoginCanceled) return@launch
// Only reached when the poll ended. If this view model is cleared first, the login stays pending.
// The confirmed login stays pending until the screen has shown the result, see onPostLoginShown.
if (polled == null) PendingBrowserLoginStore.clearIf(response)

_postLoginState.value = postLoginStateFor(polled)
}
}
}

private suspend fun pollConfirmedLogin(response: LoginResponse): LoginCompletion? {
// Canceled, expired or confirmed logins stop the poll. A poll of this login in a cleared view model may
// still be in flight, it saves the confirmed login in the store.
val isLoginPending = { PendingBrowserLoginStore.active().let { it != null && it.completion == null } }
val onConfirmed = { completion: LoginCompletion -> PendingBrowserLoginStore.complete(response, completion) }
return repository.pollLogin(response, onConfirmed, isLoginPending)
?: PendingBrowserLoginStore.active()?.completion
}

/** The login screen has shown the [state], so a result of the login is not pending anymore. */
fun onPostLoginShown(state: PostLoginViewState) {
if (state != PostLoginViewState.None) savedResponse?.let { PendingBrowserLoginStore.clearIf(it) }
}

fun loginWithQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) {
if (!startLoginOnce()) return
viewModelScope.launch {
Expand Down Expand Up @@ -123,5 +172,9 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep
LoginRepository.LoginResult.DifferentAccount -> PostLoginViewState.PostLoginDifferentAccount
}

fun cancelLogin() = repository.cancelLoginFlow()
fun cancelLogin() {
isLoginCanceled = true
PendingBrowserLoginStore.clear()
repository.cancelLoginFlow()
}
}
31 changes: 29 additions & 2 deletions app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import com.nextcloud.talk.BuildConfig
import com.nextcloud.talk.R
import com.nextcloud.talk.account.BrowserLoginActivity
import com.nextcloud.talk.account.ServerSelectionActivity
import com.nextcloud.talk.account.data.PendingBrowserLogin
import com.nextcloud.talk.account.data.PendingBrowserLoginStore
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
Expand Down Expand Up @@ -115,6 +117,26 @@ class MainActivity :
}
}

/**
* The app was opened again (e.g. by the launcher icon) while a login via the browser was unfinished. That
* destroyed the login screens, so they are opened again, with the saved login continued instead of a new one.
*/
private fun resumeBrowserLogin(pending: PendingBrowserLogin, hasAccounts: Boolean) {
val loginIntent = Intent(context, BrowserLoginActivity::class.java)
loginIntent.putExtra(BundleKeys.KEY_RESUME_BROWSER_LOGIN, true)
// To start the login again if the saved one is lost by process death.
loginIntent.putExtra(BundleKeys.KEY_BASE_URL, pending.baseUrl)
loginIntent.putExtra(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, pending.reAuth)
pending.accountToReauthorize?.let { loginIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) }

val screenBelow = when {
hasAccounts -> Intent(context, ConversationsListActivity::class.java)
isBrandingUrlSet() -> null
else -> Intent(context, ServerSelectionActivity::class.java)
}
startActivities(listOfNotNull(screenBelow, loginIntent).toTypedArray())
}

private fun isBrandingUrlSet() = !TextUtils.isEmpty(resources.getString(R.string.weblogin_url))

override fun onStart() {
Expand Down Expand Up @@ -281,10 +303,15 @@ class MainActivity :
} else {
ClosedInterfaceImpl().setUpPushTokenRegistration()
}
if (isFinishing || isDestroyed) return@launch
}
if (isFinishing || isDestroyed) return@launch

val pendingBrowserLogin = PendingBrowserLoginStore.active()
if (pendingBrowserLogin != null) {
resumeBrowserLogin(pendingBrowserLogin, users.isNotEmpty())
} else if (users.isNotEmpty()) {
openConversationList()
} else {
if (isFinishing || isDestroyed) return@launch
launchServerSelection()
}
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ object BundleKeys {
const val KEY_THREAD_ID = "KEY_THREAD_ID"
const val KEY_SEARCH_QUERY = "KEY_SEARCH_QUERY"
const val KEY_FROM_QR: String = "KEY_FROM_QR"
const val KEY_RESUME_BROWSER_LOGIN: String = "KEY_RESUME_BROWSER_LOGIN"
const val KEY_OPENED_VIA_NOTIFICATION: String = "KEY_OPENED_VIA_NOTIFICATION"
const val KEY_UPCOMING_EVENT: String = "KEY_UPCOMING_EVENT"
}
Loading
Loading