Skip to content

Commit

Permalink
Fix wallet select disambiguation cancellation crash (#362)
Browse files Browse the repository at this point in the history
* Initial refactor ActivityResultSender to facilitate cancellations

* More refactors to bring in line with best practices.

* Ensure no crashing when cancelling connnection.

* Close scenario after activity callback.

* Add no wallet found error type to sealed class hierarchy

* Start adding support for no wallet found error in ktx sample

* Make sample app use new status
  • Loading branch information
creativedrewy authored Jan 26, 2023
1 parent 79980a6 commit a843717
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 93 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
package com.solana.mobilewalletadapter.clientlib

import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.GuardedBy

interface ActivityResultSender {
fun launch(intent: Intent)
class ActivityResultSender(
rootActivity: ComponentActivity
) {
@GuardedBy("this")
private var callback: (() -> Unit)? = null

private val activityResultLauncher: ActivityResultLauncher<Intent> =
rootActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
onActivityComplete()
}

fun startActivityForResult(intent: Intent, onActivityCompleteCallback: () -> Unit) {
synchronized(this) {
check(callback == null) { "Received an activity start request while another is pending" }
callback = onActivityCompleteCallback
}

activityResultLauncher.launch(intent)
}

private fun onActivityComplete() {
synchronized(this) {
callback?.let { it() }
callback = null
}
}
}
Original file line number Diff line number Diff line change
@@ -1,102 +1,119 @@
package com.solana.mobilewalletadapter.clientlib

import android.util.Log
import android.content.ActivityNotFoundException
import com.solana.mobilewalletadapter.clientlib.protocol.JsonRpc20Client
import com.solana.mobilewalletadapter.clientlib.protocol.MobileWalletAdapterClient
import com.solana.mobilewalletadapter.clientlib.scenario.LocalAssociationIntentCreator
import com.solana.mobilewalletadapter.clientlib.scenario.LocalAssociationScenario
import com.solana.mobilewalletadapter.clientlib.scenario.Scenario
import com.solana.mobilewalletadapter.common.ProtocolContract
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import java.io.IOException
import java.util.concurrent.CancellationException
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

sealed class TransactionResult<T> {
data class Success<T>(
val payload: T
): TransactionResult<T>()

class Failure<T>(
val message: String,
val e: Exception
): TransactionResult<T>()

class NoWalletFound<T>(
val message: String
): TransactionResult<T>()
}

/**
* Convenience property to access success payload. Will be null if not successful.
*/
val <T> TransactionResult<T>.successPayload: T?
get() = (this as? TransactionResult.Success)?.payload

class MobileWalletAdapter(
private val timeout: Int = Scenario.DEFAULT_CLIENT_TIMEOUT_MS,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {

private val adapterOperations = LocalAdapterOperations(ioDispatcher)

suspend fun <T> transact(sender: ActivityResultSender, block: suspend AdapterOperations.() -> T): T {
return try {
withContext(ioDispatcher) {
val scenario = LocalAssociationScenario(timeout)
val details = scenario.associationDetails()
suspend fun <T> transact(sender: ActivityResultSender, block: suspend AdapterOperations.() -> T): TransactionResult<T> = coroutineScope {
return@coroutineScope try {
val scenario = LocalAssociationScenario(timeout)
val details = scenario.associationDetails()

val intent = LocalAssociationIntentCreator.createAssociationIntent(details.uriPrefix, details.port, details.session)
sender.launch(intent)
val intent = LocalAssociationIntentCreator.createAssociationIntent(details.uriPrefix, details.port, details.session)
sender.startActivityForResult(intent) {
launch {
delay(5000L)
scenario.close()
}
}

val client = try {
withContext(ioDispatcher) {
try {
@Suppress("BlockingMethodInNonBlockingContext")
scenario.start().get(ASSOCIATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
val client = scenario.start().get(ASSOCIATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)

adapterOperations.client = client
val result = block(adapterOperations)

TransactionResult.Success(result)
} catch (e: InterruptedException) {
Log.w(TAG, "Interrupted while waiting for local association to be ready")
throw e
TransactionResult.Failure("Interrupted while waiting for local association to be ready", e)
} catch (e: TimeoutException) {
Log.e(TAG, "Timed out waiting for local association to be ready")
throw e
TransactionResult.Failure("Timed out waiting for local association to be ready", e)
} catch (e: ExecutionException) {
Log.e(TAG, "Failed establishing local association with wallet", e.cause)
throw e
TransactionResult.Failure("Failed establishing local association with wallet", e)
} catch (e: CancellationException) {
TransactionResult.Failure("Local association was cancelled before connected", e)
} finally {
@Suppress("BlockingMethodInNonBlockingContext")
scenario.close().get(ASSOCIATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
}

adapterOperations.client = client
val result = block(adapterOperations)

@Suppress("BlockingMethodInNonBlockingContext")
scenario.close().get(ASSOCIATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)

result
}
} catch (e: ExecutionException) {
when (val cause = e.cause) {
is IOException -> {
Log.e(TAG, "IO error while sending operation", cause)
throw e
return@coroutineScope TransactionResult.Failure("IO error while sending operation", cause)
}
is TimeoutException -> {
Log.e(TAG, "Timed out while waiting for result", cause)
throw e
return@coroutineScope TransactionResult.Failure("Timed out while waiting for result", cause)
}
is MobileWalletAdapterClient.InvalidPayloadsException -> {
Log.e(TAG, "Transaction payloads invalid", cause)
throw e
return@coroutineScope TransactionResult.Failure("Transaction payloads invalid", cause)
}
is MobileWalletAdapterClient.NotSubmittedException -> {
Log.e(TAG, "Not all transactions were submitted", cause)
throw e
return@coroutineScope TransactionResult.Failure("Not all transactions were submitted", cause)
}
is JsonRpc20Client.JsonRpc20RemoteException -> {
when (cause.code) {
ProtocolContract.ERROR_AUTHORIZATION_FAILED -> Log.e(TAG, "Auth token invalid", cause)
ProtocolContract.ERROR_NOT_SIGNED -> Log.e(TAG, "User did not authorize signing", cause)
ProtocolContract.ERROR_TOO_MANY_PAYLOADS -> Log.e(TAG, "Too many payloads to sign", cause)
else -> Log.e(TAG, "Remote exception", cause)
val msg = when (cause.code) {
ProtocolContract.ERROR_AUTHORIZATION_FAILED -> "Auth token invalid"
ProtocolContract.ERROR_NOT_SIGNED -> "User did not authorize signing"
ProtocolContract.ERROR_TOO_MANY_PAYLOADS -> "Too many payloads to sign"
else -> "Remote exception"
}
throw e
return@coroutineScope TransactionResult.Failure(msg, cause)
}
is MobileWalletAdapterClient.InsecureWalletEndpointUriException -> {
Log.e(TAG, "Authorization result contained a non-HTTPS wallet base URI", cause)
throw e
return@coroutineScope TransactionResult.Failure("Authorization result contained a non-HTTPS wallet base URI", cause)
}
is JsonRpc20Client.JsonRpc20Exception -> {
Log.e(TAG, "JSON-RPC client exception", cause)
throw e
return@coroutineScope TransactionResult.Failure("JSON-RPC client exception", cause)
}
else -> throw e
else -> return@coroutineScope TransactionResult.Failure("Execution exception", e)
}
} catch (e: CancellationException) {
Log.e(TAG, "Request was cancelled", e)
throw e
return@coroutineScope TransactionResult.Failure("Request was cancelled", e)
} catch (e: InterruptedException) {
Log.e(TAG, "Request was interrupted", e)
throw e
return@coroutineScope TransactionResult.Failure("Request was interrupted", e)
} catch (e: ActivityNotFoundException) {
return@coroutineScope TransactionResult.NoWalletFound("No compatible wallet found.")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.solanamobile.ktxclientsample.activity

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
Expand All @@ -14,23 +13,21 @@ import com.solanamobile.ktxclientsample.ui.theme.KtxClientSampleTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity(), ActivityResultSender {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val sender = ActivityResultSender(this)

setContent {
KtxClientSampleTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
SampleScreen(this)
SampleScreen(sender)
}
}
}
}

override fun launch(intent: Intent) {
startActivityForResult(intent, 0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ fun SampleScreen(
}

Button(
enabled = viewState.walletFound,
elevation = ButtonDefaults.elevation(defaultElevation = 4.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.secondaryVariant
Expand Down Expand Up @@ -195,6 +196,12 @@ fun SampleScreen(
)
}

val buttonText = when {
viewState.canTransact && viewState.walletFound -> "Disconnect"
!viewState.walletFound -> "Please install a compatible wallet"
else -> "Add funds to get started"
}

Button(
modifier = Modifier.fillMaxWidth(),
enabled = viewState.canTransact,
Expand All @@ -207,7 +214,7 @@ fun SampleScreen(
) {
Text(
color = MaterialTheme.colors.onPrimary,
text = if (viewState.canTransact) "Disconnect" else "Add funds to get started"
text = buttonText
)
}

Expand Down
Loading

0 comments on commit a843717

Please sign in to comment.