diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ada4cd1..b18bb8c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,5 +6,7 @@ - An issue where signing a text message through WalletConnect did not work. - An issue where a dApp could request to get a transaction signed by a different account than the one chosen for the WalletConnect session. +- Crashing when received unexpected error from an identity provider +- Exiting the wallet after accepting an identity verification error [Unreleased]: https://github.com/Concordium/cryptox-android/compare/0.6.1-qa.5...HEAD diff --git a/app/src/main/java/com/concordium/wallet/ui/common/failed/FailedActivity.kt b/app/src/main/java/com/concordium/wallet/ui/common/failed/FailedActivity.kt index 0f6ea755..c43b9794 100644 --- a/app/src/main/java/com/concordium/wallet/ui/common/failed/FailedActivity.kt +++ b/app/src/main/java/com/concordium/wallet/ui/common/failed/FailedActivity.kt @@ -94,16 +94,16 @@ class FailedActivity : BaseActivity( private fun finishFlow() { when (viewModel.source) { FailedViewModel.Source.Identity -> { + finishAffinity() val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) - finishAffinity() } FailedViewModel.Source.Account -> { + finishAffinity() val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) - finishAffinity() } FailedViewModel.Source.Transfer -> { val intent = Intent(this, AccountDetailsActivity::class.java) diff --git a/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebViewViewModel.kt b/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebViewViewModel.kt index 5e6b4e64..5b31e862 100644 --- a/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebViewViewModel.kt +++ b/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebViewViewModel.kt @@ -1,6 +1,7 @@ package com.concordium.wallet.ui.identity.identityproviderwebview import android.app.Application +import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -32,8 +33,11 @@ import com.concordium.wallet.data.room.Identity import com.concordium.wallet.data.room.Recipient import com.concordium.wallet.data.room.WalletDatabase import com.concordium.wallet.ui.common.BackendErrorHandler +import com.concordium.wallet.ui.passphrase.recoverprocess.retrofit.IdentityProviderApiInstance import com.concordium.wallet.util.Log +import com.google.gson.JsonParseException import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.math.BigInteger @@ -50,6 +54,10 @@ class IdentityProviderWebViewViewModel(application: Application) : AndroidViewMo val errorLiveData: LiveData> get() = _errorLiveData + private val _handleIdentityVerificationUri = MutableLiveData>() + val handleIdentityVerificationUri: LiveData> + get() = _handleIdentityVerificationUri + private val _identityCreationError = MutableLiveData>() val identityCreationError: LiveData> get() = _identityCreationError @@ -110,16 +118,31 @@ class IdentityProviderWebViewViewModel(application: Application) : AndroidViewMo } } - fun getIdentityProviderUrl(): String { + /** + * Upon finish, either [handleIdentityVerificationUri] or [identityCreationError] + * gets triggered. + */ + fun beginVerification() = viewModelScope.launch(Dispatchers.IO) { + _waitingLiveData.postValue(true) + val idObjectRequest = gson.toJson(IdentityRequest(identityCreationData.idObjectRequest)) val baseUrl = identityCreationData.identityProvider.metadata.issuanceStart val delimiter = if (baseUrl.contains('?')) "&" else "?" - return if (BuildConfig.DEBUG && BuildConfig.ENV_NAME == "prod_testnet" && baseUrl.lowercase() - .contains("notabene") - ) - "${baseUrl}${delimiter}response_type=code&redirect_uri=$CALLBACK_URL&scope=identity&state=$idObjectRequest&test_flow=1" - else - "${baseUrl}${delimiter}response_type=code&redirect_uri=$CALLBACK_URL&scope=identity&state=$idObjectRequest" + try { + val verificationRedirectUri = IdentityProviderApiInstance.getVerificationRedirectUri( + verificationStartUrl = baseUrl + + "${delimiter}response_type=code" + + "&redirect_uri=$CALLBACK_URL" + + "&scope=identity" + + "&state=$idObjectRequest", + redirectUriScheme = BuildConfig.SCHEME, + ) + _handleIdentityVerificationUri.postValue(Event(Uri.parse(verificationRedirectUri))) + } catch (error: Exception) { + _identityCreationError.postValue(Event(error.message ?: error.toString())) + } finally { + _waitingLiveData.postValue(false) + } } private fun saveNewIdentity(identityObject: IdentityObject) { @@ -204,8 +227,14 @@ class IdentityProviderWebViewViewModel(application: Application) : AndroidViewMo } fun parseIdentityError(errorContent: String) { - val error: Map = + val error: Map = try { gson.fromJson(errorContent, object : TypeToken>() {}.type) + } catch (jsonException: JsonParseException) { + Log.e("Unexpected identity error content", jsonException) + _identityCreationError.value = Event(errorContent) + return + } + val map = error["error"] as Map<*, *> val event = Event(map["detail"].toString()) if (map["code"]!! == "USER_CANCEL") { diff --git a/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebviewActivity.kt b/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebviewActivity.kt index 7f71760b..231b09ba 100644 --- a/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebviewActivity.kt +++ b/app/src/main/java/com/concordium/wallet/ui/identity/identityproviderwebview/IdentityProviderWebviewActivity.kt @@ -61,7 +61,11 @@ class IdentityProviderWebviewActivity : BaseActivity( } // Have to keep this intent data in case the Activity is force killed while on the IdentityProvider website - private inner class IdentityDataPreferences(context: Context, preferenceName: String, preferenceMode: Int) : + private inner class IdentityDataPreferences( + context: Context, + preferenceName: String, + preferenceMode: Int + ) : Preferences(context, preferenceName, preferenceMode) { private inner class StoredIdentityCreationData( val version: Int, @@ -162,7 +166,7 @@ class IdentityProviderWebviewActivity : BaseActivity( viewModel.initialize(tempData) initViews() if (!viewModel.useTemporaryBackend) { - showChromeCustomTab(viewModel.getIdentityProviderUrl()) + viewModel.beginVerification() } } } @@ -202,6 +206,15 @@ class IdentityProviderWebviewActivity : BaseActivity( showError(value) } }) + viewModel.handleIdentityVerificationUri.observe(this, object : EventObserver() { + override fun onUnhandledEvent(value: Uri) { + if (hasValidCallbackUri(value)) { + handleNewIntentData(value, null) + } else { + showChromeCustomTab(value.toString()) + } + } + }) viewModel.identityCreationError.observe(this, object : EventObserver() { override fun onUnhandledEvent(value: String) { val error = BackendError(0, value) diff --git a/app/src/main/java/com/concordium/wallet/ui/passphrase/recoverprocess/retrofit/IdentityProviderApiInstance.kt b/app/src/main/java/com/concordium/wallet/ui/passphrase/recoverprocess/retrofit/IdentityProviderApiInstance.kt index 0d298155..01455c26 100644 --- a/app/src/main/java/com/concordium/wallet/ui/passphrase/recoverprocess/retrofit/IdentityProviderApiInstance.kt +++ b/app/src/main/java/com/concordium/wallet/ui/passphrase/recoverprocess/retrofit/IdentityProviderApiInstance.kt @@ -1,59 +1,122 @@ package com.concordium.wallet.ui.passphrase.recoverprocess.retrofit +import android.net.Uri +import com.concordium.wallet.BuildConfig import com.concordium.wallet.data.model.RecoverErrorResponse import com.concordium.wallet.data.model.RecoverResponse import com.concordium.wallet.util.Log import com.google.gson.Gson +import okhttp3.CacheControl import okhttp3.OkHttpClient +import okhttp3.Request import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.create -class IdentityProviderApiInstance { - companion object { - private val retrofit by lazy { - val logging = HttpLoggingInterceptor() - logging.setLevel(HttpLoggingInterceptor.Level.BODY) - val client = OkHttpClient.Builder() - .addInterceptor(logging) - .build() - Retrofit.Builder() - .baseUrl("https://some.api.url/") - .addConverterFactory(GsonConverterFactory.create()) - .client(client) - .build() - } +object IdentityProviderApiInstance { + private val httpClient: OkHttpClient by lazy { + val logging = HttpLoggingInterceptor() + logging.setLevel( + if (BuildConfig.DEBUG) + HttpLoggingInterceptor.Level.BODY + else + HttpLoggingInterceptor.Level.NONE + ) + OkHttpClient.Builder() + .addInterceptor(logging) + .build() + } - private val api: IdentityProviderApi by lazy { - retrofit.create(IdentityProviderApi::class.java) - } + private val retrofit by lazy { + Retrofit.Builder() + .baseUrl("https://some.api.url/") + .addConverterFactory(GsonConverterFactory.create()) + .client(httpClient) + .build() + } - suspend fun safeRecoverCall(url: String?): Pair { - try { - val response = api.recover(url) - if (response.isSuccessful) { - response.body()?.let { - if (it.value != null) - return Pair(true, it) - else - return Pair(true, null) - } + private val api: IdentityProviderApi by lazy(retrofit::create) + + suspend fun safeRecoverCall(url: String?): Pair { + try { + val response = api.recover(url) + if (response.isSuccessful) { + response.body()?.let { + if (it.value != null) + return Pair(true, it) + else + return Pair(true, null) + } + } else { + return if (response.errorBody() != null && response.code() == 404) { + val errorResponse: RecoverErrorResponse = Gson().fromJson( + response.errorBody()!!.charStream(), + RecoverErrorResponse::class.java + ) + Log.d("${errorResponse.code} ${errorResponse.message} on $url") + Pair(true, null) } else { - return if (response.errorBody() != null && response.code() == 404) { - val errorResponse: RecoverErrorResponse = Gson().fromJson( - response.errorBody()!!.charStream(), - RecoverErrorResponse::class.java - ) - Log.d("${errorResponse.code} ${errorResponse.message} on $url") - Pair(true, null) - } else { - Pair(false, null) - } + Pair(false, null) } - } catch (t: Throwable) { - Log.d(Log.toString(t)) } - return Pair(false, null) + } catch (t: Throwable) { + Log.d(Log.toString(t)) } + return Pair(false, null) } + + /** + * When the ID verification start URL is built, it must be requested + * internally before passing to a browser. The final URI will be obtained + * after following all the redirects. It will help to detect errors + * such as idCredPub duplication faster. + * + * @param verificationStartUrl a URL crafted to start the verification process + * @param redirectUriScheme a scheme which the app expects for result redirect + * + * @return a URL to proceed with the verification or a URI with [redirectUriScheme] + * if the result is available instantly. + */ + fun getVerificationRedirectUri( + verificationStartUrl: String, + redirectUriScheme: String, + ): String = + httpClient + .newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + .newCall( + Request.Builder() + .url(verificationStartUrl) + .cacheControl(CacheControl.FORCE_NETWORK) + .build() + ) + .execute() + .use { response -> + check(response.isRedirect) { + "The identity provider did not redirect as expected" + } + + val redirectLocation = response.header("Location") + ?: error("Can't find the location in the identity provider response") + + return@use if (Uri.parse(redirectLocation).scheme in setOf( + "http", + "https", + redirectUriScheme, + ) + ) { + // Return the location if it is a URI with an allowed scheme: + // HTTP or HTTPS to proceed with the verification on a web page, + // or the app redirect URI scheme to get the instant result. + redirectLocation + } else { + // Otherwise try to resolve the location against the base URL + // in case it is a relative path. + response.request.url.resolve(redirectLocation)?.toString() + ?: error("Can't resolve the redirect: $redirectLocation") + } + } }