├── .gitignore ├── README.md ├── app ├── .gitignore ├── README.md ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── example │ │ └── solanakotlincomposescaffold │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── solanakotlincomposescaffold │ │ │ ├── MainActivity.kt │ │ │ ├── SolanaKotlinComposeScaffoldApp.kt │ │ │ ├── di │ │ │ └── SolanaKotlinComposeScaffoldModule.kt │ │ │ ├── networking │ │ │ └── HttpDriver.kt │ │ │ ├── ui │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── usecase │ │ │ ├── AccountBalanceUseCase.kt │ │ │ ├── ConfirmTransactionUseCase.kt │ │ │ ├── LatestBlockhashUseCase.kt │ │ │ ├── MemoTransactionUseCase.kt │ │ │ ├── PersistenceUseCase.kt │ │ │ ├── RequestAirdropUseCase.kt │ │ │ └── SendTransactionsUseCase.kt │ │ │ └── viewmodel │ │ │ └── MainViewModel.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── example │ └── solanakotlincomposescaffold │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── screenshot1.png ├── screenshot2.png └── screenshot3.png └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SolanaKotlinComposeScaffold 2 | 3 | A boilerplate example app for Solana Mobile dApps built using Jetpack Compose. 4 | It provides an interface to connect to locally installed wallet apps (that are MWA-compatible), view your account balance on devnet, and request an airdrop of SOL. 5 | 6 | ## Featured Libarires 7 | 8 | - [Mobile Wallet Adapter (`clientlib-ktx`)](https://github.com/solana-mobile/mobile-wallet-adapter/tree/main/js/packages/mobile-wallet-adapter-protocol) for connecting to wallets and signing transactions/messages 9 | - [web3-solana](https://github.com/solana-mobile/web3-core) for Solana primitive types (`SolanaPublicKey`) and constructing messages/transactions. 10 | - [rpc-core](https://github.com/solana-mobile/rpc-core) for constructing and sending [Solana RPC requests](https://docs.solana.com/api/http). 11 | 12 | 13 | 14 | 17 | 20 | 23 | 24 |
15 | Scaffold dApp Screenshot 1 16 | 18 | Scaffold dApp Screenshot 2 19 | 21 | Scaffold dApp Screenshot 3 22 |
25 | 26 | ## Prerequisites 27 | 28 | You'll need to first setup your environment for Android development. Follow the [Prerequisite Setup Guide](https://docs.solanamobile.com/getting-started/development-setup). 29 | 30 | Follow the guide to make sure you: 31 | 32 | - setup your Android and React Native development environment. 33 | - have an Android device or emulator. 34 | - install an MWA compliant wallet app on your device/emulator. 35 | 36 | ## Usage 37 | 38 | 1. Initialize project template 39 | 40 | ```bash 41 | git clone https://github.com/solana-mobile/solana-kotlin-compose-scaffold.git 42 | ``` 43 | 44 | 2. Open the project on Android Studio > Open > "SolanaKotlinComposeScaffold/app/build.gradle.kts" 45 | 46 | 3. Start your emulator/device and build the app 47 | 48 | ## Troubleshooting 49 | 50 | - `Compatible wallet not found.` 51 | - Make sure you install a compatible MWA wallet on your device, like Phantom, Solflare, Ultimate, or `fakewallet`. Follow 52 | the [setup guide](https://docs.solanamobile.com/getting-started/development-setup). 53 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/README.md -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("kotlin-kapt") 5 | id("kotlinx-serialization") 6 | id("dagger.hilt.android.plugin") 7 | } 8 | 9 | android { 10 | namespace = "com.example.solanakotlincomposescaffold" 11 | compileSdk = 33 12 | android.buildFeatures.buildConfig = true 13 | 14 | defaultConfig { 15 | applicationId = "com.example.solanakotlincomposescaffold" 16 | minSdk = 24 17 | targetSdk = 33 18 | versionCode = 1 19 | versionName = "1.0" 20 | buildConfigField("String", "RPC_URI", "\"https://api.devnet.solana.com\"") 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | vectorDrawables { 23 | useSupportLibrary = true 24 | } 25 | } 26 | 27 | buildTypes { 28 | release { 29 | isMinifyEnabled = false 30 | proguardFiles( 31 | getDefaultProguardFile("proguard-android-optimize.txt"), 32 | "proguard-rules.pro" 33 | ) 34 | } 35 | } 36 | compileOptions { 37 | sourceCompatibility = JavaVersion.VERSION_11 38 | targetCompatibility = JavaVersion.VERSION_11 39 | } 40 | kotlinOptions { 41 | jvmTarget = "11" 42 | } 43 | buildFeatures { 44 | compose = true 45 | } 46 | composeOptions { 47 | kotlinCompilerExtensionVersion = "1.5.3" 48 | } 49 | packaging { 50 | resources { 51 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 52 | excludes += "/META-INF/versions/9/previous-compilation-data.bin" 53 | } 54 | } 55 | } 56 | 57 | dependencies { 58 | 59 | implementation("androidx.core:core-ktx:1.9.0") 60 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") 61 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1") 62 | implementation("androidx.activity:activity-compose:1.7.0") 63 | implementation(platform("androidx.compose:compose-bom:2023.03.00")) 64 | implementation("androidx.compose.ui:ui") 65 | implementation("androidx.compose.ui:ui-graphics") 66 | implementation("androidx.compose.ui:ui-tooling-preview") 67 | implementation("androidx.compose.material3:material3") 68 | implementation ("androidx.hilt:hilt-navigation-compose:1.0.0") 69 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0-RC") 70 | implementation("io.ktor:ktor-client-core:2.3.4") 71 | implementation("io.ktor:ktor-client-android:2.3.4") 72 | 73 | implementation("io.github.funkatronics:multimult:0.2.0") 74 | implementation("com.solanamobile:web3-solana:0.2.2") 75 | implementation("com.solanamobile:rpc-core:0.2.3") 76 | implementation("com.solanamobile:mobile-wallet-adapter-clientlib-ktx:2.0.0") 77 | 78 | implementation("com.google.dagger:hilt-android:2.48") 79 | kapt("com.google.dagger:hilt-compiler:2.48") 80 | 81 | testImplementation("junit:junit:4.13.2") 82 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 83 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") 84 | androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) 85 | androidTestImplementation("androidx.compose.ui:ui-test-junit4") 86 | 87 | debugImplementation("androidx.compose.ui:ui-tooling") 88 | debugImplementation("androidx.compose.ui:ui-test-manifest") 89 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/solanakotlincomposescaffold/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.example.solanakotlincomposescaffold", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.compose.setContent 9 | import androidx.compose.foundation.border 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.WindowInsets 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.height 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.shape.RoundedCornerShape 19 | import androidx.compose.material3.Button 20 | import androidx.compose.material3.Card 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Surface 24 | import androidx.compose.material3.Text 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.runtime.LaunchedEffect 27 | import androidx.compose.runtime.collectAsState 28 | import androidx.compose.runtime.getValue 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.graphics.Color 31 | import androidx.compose.ui.text.font.FontWeight 32 | import androidx.compose.ui.tooling.preview.Preview 33 | import androidx.compose.ui.unit.dp 34 | import androidx.hilt.navigation.compose.hiltViewModel 35 | import com.example.solanakotlincomposescaffold.ui.theme.SolanaKotlinComposeScaffoldTheme 36 | import com.example.solanakotlincomposescaffold.viewmodel.MainViewModel 37 | import com.solana.mobilewalletadapter.clientlib.ActivityResultSender 38 | import dagger.hilt.android.AndroidEntryPoint 39 | import androidx.compose.material3.Scaffold 40 | import androidx.compose.material3.SnackbarDuration 41 | import androidx.compose.material3.SnackbarHost 42 | import androidx.compose.material3.SnackbarHostState 43 | import androidx.compose.runtime.remember 44 | import androidx.compose.runtime.rememberCoroutineScope 45 | import androidx.compose.ui.platform.LocalContext 46 | import com.solana.publickey.SolanaPublicKey 47 | import androidx.compose.foundation.text.ClickableText 48 | import androidx.compose.ui.text.AnnotatedString 49 | import androidx.compose.ui.text.SpanStyle 50 | import androidx.compose.ui.text.style.TextDecoration 51 | import androidx.compose.ui.unit.sp 52 | import com.funkatronics.encoders.Base58 53 | 54 | @AndroidEntryPoint 55 | class MainActivity : ComponentActivity() { 56 | override fun onCreate(savedInstanceState: Bundle?) { 57 | super.onCreate(savedInstanceState) 58 | 59 | val sender = ActivityResultSender(this) 60 | 61 | setContent { 62 | SolanaKotlinComposeScaffoldTheme { 63 | // A surface container using the 'background' color from the theme 64 | Surface( 65 | modifier = Modifier.fillMaxSize(), 66 | color = MaterialTheme.colorScheme.background 67 | ) { 68 | Column() { 69 | MainScreen(sender) 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | @OptIn(ExperimentalMaterial3Api::class) 78 | @Preview(showBackground = true) 79 | @Composable 80 | fun MainScreen( 81 | intentSender: ActivityResultSender? = null, 82 | viewModel: MainViewModel = hiltViewModel() 83 | ) { 84 | val viewState by viewModel.viewState.collectAsState() 85 | val snackbarHostState = remember { SnackbarHostState() } 86 | val coroutineScope = rememberCoroutineScope() 87 | 88 | Scaffold( 89 | topBar = { 90 | Text( 91 | text = "Solana Compose dApp Scaffold", 92 | style = MaterialTheme.typography.titleLarge, 93 | fontWeight = FontWeight.Bold, 94 | modifier = Modifier 95 | .padding(all = 24.dp) 96 | ) 97 | }, 98 | containerColor = Color.Transparent, 99 | contentColor = MaterialTheme.colorScheme.onBackground, 100 | contentWindowInsets = WindowInsets(12, 12, 12, 12), 101 | snackbarHost = { SnackbarHost(snackbarHostState) }, 102 | ) { padding -> 103 | 104 | LaunchedEffect(Unit) { 105 | viewModel.loadConnection() 106 | } 107 | 108 | LaunchedEffect(viewState.snackbarMessage) { 109 | viewState.snackbarMessage?.let { message -> 110 | snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) 111 | viewModel.clearSnackBar() 112 | } 113 | } 114 | 115 | Column( 116 | modifier = Modifier 117 | .padding(padding) 118 | ) { 119 | 120 | Section( 121 | sectionTitle = "Messages:", 122 | ) { 123 | Button( 124 | onClick = { 125 | if (intentSender != null && viewState.canTransact) 126 | viewModel.signMessage(intentSender, "Hello Solana!") 127 | else 128 | viewModel.disconnect() 129 | }, 130 | modifier = Modifier.fillMaxWidth() 131 | ) { 132 | Text(text = "Sign a message") 133 | } 134 | } 135 | 136 | Section( 137 | sectionTitle = "Transactions:", 138 | ) { 139 | Button( 140 | onClick = { 141 | if (intentSender != null && viewState.canTransact) 142 | viewModel.signTransaction(intentSender) 143 | else 144 | viewModel.disconnect() 145 | }, 146 | modifier = Modifier.fillMaxWidth() 147 | ) { 148 | Text(text = "Sign a Transaction (deprecated)") 149 | } 150 | Button( 151 | onClick = { 152 | if (intentSender != null && viewState.canTransact) 153 | viewModel.publishMemo(intentSender, "Hello Solana!") 154 | else 155 | viewModel.disconnect() 156 | }, 157 | modifier = Modifier.fillMaxWidth() 158 | ) { 159 | Text(text = "Send a Memo Transaction") 160 | } 161 | 162 | val memoTxSignature = viewState.memoTxSignature 163 | if (memoTxSignature != null) { 164 | ExplorerHyperlink(memoTxSignature) 165 | } 166 | } 167 | 168 | Spacer(modifier = Modifier.weight(1f)) 169 | 170 | if (viewState.canTransact) 171 | AccountInfo( 172 | walletName = viewState.userLabel, 173 | address = viewState.userAddress, 174 | balance = viewState.solBalance 175 | ) 176 | 177 | Row() { 178 | if (viewState.canTransact) 179 | Button( 180 | onClick = { 181 | viewModel.requestAirdrop(SolanaPublicKey(Base58.decode(viewState.userAddress))) 182 | }, 183 | modifier = Modifier 184 | .weight(1f) 185 | .padding(end = 4.dp) 186 | .fillMaxWidth() 187 | 188 | ) { 189 | Text("Request Airdrop") 190 | } 191 | Button( 192 | onClick = { 193 | if (intentSender != null && !viewState.canTransact) 194 | viewModel.connect(intentSender) 195 | else 196 | viewModel.disconnect() 197 | }, 198 | modifier = Modifier 199 | .weight(1f) 200 | .padding(start = 4.dp) 201 | .fillMaxWidth() 202 | ) { 203 | Text(if (viewState.canTransact) "Disconnect" else "Connect") 204 | } 205 | } 206 | } 207 | } 208 | } 209 | 210 | 211 | @Composable 212 | fun Section(sectionTitle: String, content: @Composable () -> Unit) { 213 | Column(modifier = Modifier.padding(bottom = 16.dp)) { 214 | Text( 215 | text = sectionTitle, 216 | style = MaterialTheme.typography.titleLarge, 217 | fontWeight = FontWeight.Bold, 218 | modifier = Modifier.padding(bottom = 16.dp) 219 | ) 220 | content() 221 | } 222 | } 223 | 224 | @Composable 225 | fun AccountInfo(walletName: String, address: String, balance: Number) { 226 | Card( 227 | shape = RoundedCornerShape(16.dp), 228 | modifier = Modifier 229 | .padding(bottom = 8.dp) 230 | .fillMaxWidth() 231 | .border(1.dp, Color.Black, RoundedCornerShape(16.dp)) 232 | ) { 233 | Column( 234 | modifier = Modifier 235 | .fillMaxWidth() 236 | .padding(16.dp) 237 | ) { 238 | Text( 239 | text = "Connected Wallet", 240 | style = MaterialTheme.typography.bodyLarge, 241 | fontWeight = FontWeight.Bold, 242 | ) 243 | // Wallet name and address 244 | Text( 245 | text = "$walletName ($address)", 246 | style = MaterialTheme.typography.bodyMedium, 247 | color = Color.Gray 248 | ) 249 | 250 | 251 | Spacer(modifier = Modifier.height(8.dp)) 252 | 253 | // Account balance 254 | Text( 255 | text = "$balance SOL", // TODO: Nicely format the displayed number (e.g: 0.089 SOL) 256 | style = MaterialTheme.typography.headlineLarge 257 | ) 258 | } 259 | } 260 | } 261 | 262 | @Composable 263 | fun ExplorerHyperlink(txSignature: String) { 264 | val context = LocalContext.current 265 | val url = "https://explorer.solana.com/tx/${txSignature}?cluster=devnet" 266 | val annotatedText = AnnotatedString.Builder("View your memo on the ").apply { 267 | pushStyle( 268 | SpanStyle( 269 | color = Color.Blue, 270 | textDecoration = TextDecoration.Underline, 271 | fontSize = 16.sp 272 | ) 273 | ) 274 | append("explorer.") 275 | } 276 | 277 | ClickableText( 278 | text = annotatedText.toAnnotatedString(), 279 | onClick = { 280 | openUrl(context, url) 281 | } 282 | ) 283 | } 284 | 285 | fun openUrl(context: Context, url: String) { 286 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) 287 | context.startActivity(intent) 288 | } 289 | 290 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/SolanaKotlinComposeScaffoldApp.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class SolanaKotlinComposeScaffoldApp : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/di/SolanaKotlinComposeScaffoldModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.di 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.net.Uri 6 | import com.solana.mobilewalletadapter.clientlib.ConnectionIdentity 7 | import com.solana.mobilewalletadapter.clientlib.MobileWalletAdapter 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.android.components.ViewModelComponent 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | 14 | val solanaUri = Uri.parse("https://solana.com") 15 | val iconUri = Uri.parse("favicon.ico") 16 | val identityName = "Solana" 17 | 18 | @InstallIn( 19 | ViewModelComponent::class 20 | ) 21 | @Module 22 | class SolanaKotlinComposeScaffoldModule { 23 | 24 | @Provides 25 | fun providesSharedPrefs(@ApplicationContext ctx: Context): SharedPreferences { 26 | return ctx.getSharedPreferences("scaffold_prefs", Context.MODE_PRIVATE) 27 | } 28 | 29 | @Provides 30 | fun providesMobileWalletAdapter(): MobileWalletAdapter { 31 | return MobileWalletAdapter(connectionIdentity = ConnectionIdentity( 32 | identityUri = solanaUri, 33 | iconUri = iconUri, 34 | identityName = identityName 35 | )) 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/networking/HttpDriver.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.networking 2 | 3 | import com.solana.networking.HttpNetworkDriver 4 | import com.solana.networking.HttpRequest 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.engine.android.Android 7 | import io.ktor.client.request.header 8 | import io.ktor.client.request.request 9 | import io.ktor.client.request.setBody 10 | import io.ktor.client.statement.bodyAsText 11 | import io.ktor.http.HttpMethod 12 | 13 | class KtorHttpDriver : HttpNetworkDriver { 14 | override suspend fun makeHttpRequest(request: HttpRequest): String = 15 | HttpClient(Android).use { client -> 16 | client.request(request.url) { 17 | method = HttpMethod.parse(request.method) 18 | request.properties.forEach { (k, v) -> 19 | header(k, v) 20 | } 21 | setBody(request.body) 22 | }.bodyAsText() 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | 18 | private val DarkColorScheme = darkColorScheme( 19 | primary = Purple80, 20 | secondary = PurpleGrey80, 21 | tertiary = Pink80 22 | ) 23 | 24 | private val LightColorScheme = lightColorScheme( 25 | primary = Purple40, 26 | secondary = PurpleGrey40, 27 | tertiary = Pink40 28 | 29 | /* Other default colors to override 30 | background = Color(0xFFFFFBFE), 31 | surface = Color(0xFFFFFBFE), 32 | onPrimary = Color.White, 33 | onSecondary = Color.White, 34 | onTertiary = Color.White, 35 | onBackground = Color(0xFF1C1B1F), 36 | onSurface = Color(0xFF1C1B1F), 37 | */ 38 | ) 39 | 40 | @Composable 41 | fun SolanaKotlinComposeScaffoldTheme( 42 | darkTheme: Boolean = isSystemInDarkTheme(), 43 | // Dynamic color is available on Android 12+ 44 | dynamicColor: Boolean = true, 45 | content: @Composable () -> Unit 46 | ) { 47 | val colorScheme = when { 48 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 49 | val context = LocalContext.current 50 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 51 | } 52 | 53 | darkTheme -> DarkColorScheme 54 | else -> LightColorScheme 55 | } 56 | val view = LocalView.current 57 | if (!view.isInEditMode) { 58 | SideEffect { 59 | val window = (view.context as Activity).window 60 | window.statusBarColor = colorScheme.primary.toArgb() 61 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 62 | } 63 | } 64 | 65 | MaterialTheme( 66 | colorScheme = colorScheme, 67 | typography = Typography, 68 | content = content 69 | ) 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/AccountBalanceUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.example.solanakotlincomposescaffold.networking.KtorHttpDriver 6 | import com.solana.networking.Rpc20Driver 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import java.util.UUID 10 | import kotlinx.serialization.Serializable 11 | import kotlinx.serialization.json.buildJsonArray 12 | import com.solana.publickey.SolanaPublicKey 13 | import com.solana.rpccore.JsonRpc20Request 14 | import kotlinx.serialization.json.add 15 | 16 | object AccountBalanceUseCase { 17 | private val TAG = AccountBalanceUseCase::class.simpleName 18 | suspend operator fun invoke(rpcUri: Uri, address: SolanaPublicKey): Long = 19 | withContext(Dispatchers.IO) { 20 | val rpc = Rpc20Driver(rpcUri.toString(), KtorHttpDriver()) 21 | val requestId = UUID.randomUUID().toString() 22 | val request = createBalanceRequest(address, requestId) 23 | val response = rpc.makeRequest(request, BalanceResponse.serializer()) 24 | 25 | response.error?.let { error -> 26 | throw InvalidAccountException("Could not fetch balance for account [${address.base58()}]: ${error.code}, ${error.message}") 27 | } 28 | 29 | Log.d(TAG, "getBalance pubKey=${address.base58()}, balance=${response.result}") 30 | return@withContext response.result!!.value 31 | } 32 | 33 | private fun createBalanceRequest(address: SolanaPublicKey, requestId: String = "1") = 34 | JsonRpc20Request( 35 | method = "getBalance", 36 | params = buildJsonArray { 37 | add(address.base58()) 38 | }, 39 | requestId 40 | ) 41 | 42 | @Serializable 43 | data class BalanceResponse(val value: Long) 44 | class InvalidAccountException(message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause) 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/ConfirmTransactionUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.example.solanakotlincomposescaffold.networking.KtorHttpDriver 6 | import com.solana.networking.Rpc20Driver 7 | import com.solana.rpccore.JsonRpc20Request 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.delay 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.Serializable 12 | import kotlinx.serialization.json.JsonElement 13 | import kotlinx.serialization.json.add 14 | import kotlinx.serialization.json.buildJsonArray 15 | import kotlinx.serialization.json.buildJsonObject 16 | import kotlinx.serialization.json.put 17 | import java.util.UUID 18 | 19 | object ConfirmTransactionUseCase { 20 | private val TAG = ConfirmTransactionUseCase::class.simpleName 21 | 22 | suspend operator fun invoke(rpcUri: Uri, signature: String): Boolean = 23 | withContext(Dispatchers.IO) { 24 | val rpc = Rpc20Driver(rpcUri.toString(), KtorHttpDriver()) 25 | val requestId = UUID.randomUUID().toString() 26 | val request = createSignatureStatusRequest(signature, true, requestId) 27 | var confirmed = false 28 | val timeoutMillis = 30000 29 | val startTime = System.currentTimeMillis() 30 | val targetConfirmations = 31 31 | 32 | while (!confirmed) { 33 | 34 | val response = rpc.makeRequest(request, SignatureStatusResponse.serializer()) 35 | 36 | response.error?.let { error -> 37 | throw InvalidTransactionSignature("Signature Status Invalid: ${error.code}, ${error.message}") 38 | } 39 | 40 | response.result?.value?.find { it?.err != null }?.let { erroredStatus -> 41 | throw SignatureStatusError(erroredStatus.err.toString()) 42 | } 43 | 44 | Log.d(TAG, "getSignatureStatuses: signature = $signature, status=${response.result?.value}") 45 | 46 | val confirmations = response.result?.value?.get(0)?.confirmations ?: 0 47 | confirmed = response.result?.value?.get(0)?.confirmationStatus == "finalized" 48 | 49 | val retryTime = (targetConfirmations - confirmations)*300L 50 | 51 | if (!confirmed) delay(retryTime) 52 | if (System.currentTimeMillis() - startTime > timeoutMillis) break 53 | } 54 | 55 | confirmed 56 | } 57 | 58 | private fun createSignatureStatusRequest(signature: String, searchHistory: Boolean = true, requestId: String = "1") = 59 | JsonRpc20Request( 60 | method = "getSignatureStatuses", 61 | params = buildJsonArray { 62 | add(buildJsonArray { 63 | add(signature) 64 | }) 65 | add(buildJsonObject { 66 | put("searchTransactionHistory", searchHistory) 67 | }) 68 | }, 69 | requestId 70 | ) 71 | 72 | @Serializable 73 | class SignatureStatusResponse(val value: List) 74 | 75 | @Serializable 76 | data class SignatureStatus( 77 | val slot: Long, 78 | val confirmations: Int?, 79 | val err: JsonElement?, 80 | val confirmationStatus: String? 81 | ) 82 | 83 | sealed class SignatureStatusException(message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause) 84 | class InvalidTransactionSignature(message: String? = null, cause: Throwable? = null) : SignatureStatusException(message, cause) 85 | class SignatureStatusError(message: String? = null, cause: Throwable? = null) : SignatureStatusException(message, cause) 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/LatestBlockhashUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.example.solanakotlincomposescaffold.networking.KtorHttpDriver 6 | import com.solana.networking.Rpc20Driver 7 | import com.solana.rpccore.JsonRpc20Request 8 | import com.solana.transaction.Blockhash 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.Serializable 12 | import kotlinx.serialization.json.addJsonObject 13 | import kotlinx.serialization.json.buildJsonArray 14 | import kotlinx.serialization.json.put 15 | import java.util.UUID 16 | 17 | 18 | object RecentBlockhashUseCase { 19 | private val TAG = RequestAirdropUseCase::class.simpleName 20 | 21 | suspend operator fun invoke(rpcUri: Uri, commitment: String = "confirmed"): Blockhash = 22 | withContext(Dispatchers.IO) { 23 | val rpc = Rpc20Driver(rpcUri.toString(), KtorHttpDriver()) 24 | val requestId = UUID.randomUUID().toString() 25 | val request = createBlockhashRequest(commitment, requestId) 26 | val response = rpc.makeRequest(request, BlockhashResponse.serializer()) 27 | 28 | response.error?.let { error -> 29 | throw BlockhashException("Could not fetch latest blockhash: ${error.code}, ${error.message}") 30 | } 31 | 32 | Log.d(TAG, "getLatestBlockhash blockhash=${response.result?.value?.blockhash}") 33 | 34 | Blockhash.from(response.result?.value?.blockhash 35 | ?: throw BlockhashException("Could not fetch latest blockhash: UnknownError")) 36 | } 37 | 38 | private fun createBlockhashRequest(commitment: String = "confirmed", requestId: String = "1") = 39 | JsonRpc20Request( 40 | method = "getLatestBlockhash", 41 | params = buildJsonArray { 42 | addJsonObject { 43 | put("commitment", commitment) 44 | } 45 | }, 46 | requestId 47 | ) 48 | 49 | 50 | @Serializable 51 | class BlockhashResponse(val value: BlockhashInfo) 52 | 53 | @Serializable 54 | class BlockhashInfo( 55 | val blockhash: String, 56 | val lastValidBlockHeight: Long 57 | ) 58 | 59 | class BlockhashException(message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause) 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/MemoTransactionUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.net.Uri 4 | import com.solana.publickey.SolanaPublicKey 5 | import com.solana.transaction.AccountMeta 6 | import com.solana.transaction.Message 7 | import com.solana.transaction.Transaction 8 | import com.solana.transaction.TransactionInstruction 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | 12 | object MemoTransactionUseCase { 13 | private val TAG = AccountBalanceUseCase::class.simpleName 14 | private val memoProgramId = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" 15 | 16 | suspend operator fun invoke(rpcUri: Uri, address: SolanaPublicKey, message: String): Transaction = 17 | withContext(Dispatchers.IO) { 18 | // Solana Memo Program 19 | val memoProgramId = SolanaPublicKey.from(memoProgramId) 20 | val memoInstruction = TransactionInstruction( 21 | memoProgramId, 22 | listOf(AccountMeta(address, true, true)), 23 | message.encodeToByteArray() 24 | ) 25 | 26 | // Build Message 27 | val blockhash = RecentBlockhashUseCase(rpcUri) 28 | val memoTxMessage = Message.Builder() 29 | .addInstruction(memoInstruction) 30 | .setRecentBlockhash(blockhash) 31 | .build() 32 | return@withContext Transaction(memoTxMessage) 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/PersistenceUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.content.SharedPreferences 4 | import com.solana.publickey.SolanaPublicKey 5 | import javax.inject.Inject 6 | 7 | sealed class WalletConnection 8 | 9 | object NotConnected : WalletConnection() 10 | 11 | data class Connected( 12 | val publicKey: SolanaPublicKey, 13 | val accountLabel: String, 14 | val authToken: String 15 | ): WalletConnection() 16 | 17 | class PersistenceUseCase @Inject constructor( 18 | private val sharedPreferences: SharedPreferences 19 | ) { 20 | 21 | private var connection: WalletConnection = NotConnected 22 | 23 | fun getWalletConnection(): WalletConnection { 24 | return when(connection) { 25 | is Connected -> connection 26 | is NotConnected -> { 27 | val key = sharedPreferences.getString(PUBKEY_KEY, "") 28 | val accountLabel = sharedPreferences.getString(ACCOUNT_LABEL, "") ?: "" 29 | val token = sharedPreferences.getString(AUTH_TOKEN_KEY, "") 30 | 31 | val newConn = if (key.isNullOrEmpty() || token.isNullOrEmpty()) { 32 | NotConnected 33 | } else { 34 | Connected(SolanaPublicKey.from(key), accountLabel, token) 35 | } 36 | 37 | return newConn 38 | } 39 | } 40 | } 41 | 42 | fun persistConnection(pubKey: SolanaPublicKey, accountLabel: String, token: String) { 43 | sharedPreferences.edit().apply { 44 | putString(PUBKEY_KEY, pubKey.base58()) 45 | putString(ACCOUNT_LABEL, accountLabel) 46 | putString(AUTH_TOKEN_KEY, token) 47 | }.apply() 48 | 49 | connection = Connected(pubKey, accountLabel, token) 50 | } 51 | 52 | fun clearConnection() { 53 | sharedPreferences.edit().apply { 54 | putString(PUBKEY_KEY, "") 55 | putString(ACCOUNT_LABEL, "") 56 | putString(AUTH_TOKEN_KEY, "") 57 | }.apply() 58 | 59 | connection = NotConnected 60 | } 61 | 62 | companion object { 63 | const val PUBKEY_KEY = "stored_pubkey" 64 | const val ACCOUNT_LABEL = "stored_account_label" 65 | const val AUTH_TOKEN_KEY = "stored_auth_token" 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/RequestAirdropUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.example.solanakotlincomposescaffold.networking.KtorHttpDriver 6 | import com.solana.networking.Rpc20Driver 7 | import com.solana.publickey.SolanaPublicKey 8 | import com.solana.rpccore.JsonRpc20Request 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.builtins.serializer 12 | import kotlinx.serialization.json.add 13 | import kotlinx.serialization.json.buildJsonArray 14 | import java.util.UUID 15 | 16 | object RequestAirdropUseCase { 17 | private val TAG = RequestAirdropUseCase::class.simpleName 18 | 19 | suspend operator fun invoke(rpcUri: Uri, address: SolanaPublicKey, lamports: Long): String = 20 | withContext(Dispatchers.IO) { 21 | val rpc = Rpc20Driver(rpcUri.toString(), KtorHttpDriver()) 22 | val requestId = UUID.randomUUID().toString() 23 | val request = createAirdropRequest(address, lamports, requestId) 24 | val response = rpc.makeRequest(request, String.serializer()) 25 | 26 | response.error?.let { error -> 27 | throw AirdropFailedException("Airdrop failed: ${error.code}, ${error.message}") 28 | } 29 | 30 | Log.d(TAG, "requestAirdrop pubKey=${address.base58()}, signature(base58)=${response.result}") 31 | 32 | response.result ?: throw AirdropFailedException("Airdrop failed: Unknown Error") 33 | } 34 | 35 | private fun createAirdropRequest(address: SolanaPublicKey, lamports: Long, requestId: String = "1") = 36 | JsonRpc20Request( 37 | method = "requestAirdrop", 38 | params = buildJsonArray { 39 | add(address.base58()) 40 | add(lamports) 41 | }, 42 | requestId 43 | ) 44 | 45 | class AirdropFailedException(message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause) 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/usecase/SendTransactionsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.usecase 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.example.solanakotlincomposescaffold.networking.KtorHttpDriver 6 | import com.funkatronics.encoders.Base58 7 | import com.solana.networking.Rpc20Driver 8 | import com.solana.rpccore.JsonRpc20Request 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.serialization.builtins.serializer 12 | import kotlinx.serialization.json.add 13 | import kotlinx.serialization.json.buildJsonArray 14 | 15 | object SendTransactionsUseCase { 16 | private val TAG = SendTransactionsUseCase::class.simpleName 17 | 18 | suspend operator fun invoke(rpcUri: Uri, transactions: List) { 19 | withContext(Dispatchers.IO) { 20 | val signatures = MutableList(transactions.size) { null } 21 | transactions.forEachIndexed { i, transaction -> 22 | val rpc = Rpc20Driver(rpcUri.toString(), KtorHttpDriver()) 23 | val request = createSendTransactionRequest(transaction, i.toString()) 24 | val response = rpc.makeRequest(request, String.serializer()) 25 | signatures[i] = if (response.error == null) response.result else { 26 | Log.e(TAG, "Failed sending transaction: ${response.error!!.code}") 27 | null 28 | } 29 | 30 | if (signatures.contains(null)) 31 | throw InvalidTransactionsException(signatures.map { it != null }.toBooleanArray()) 32 | } 33 | } 34 | } 35 | 36 | private fun createSendTransactionRequest(transaction: ByteArray, requestId: String = "1") = 37 | JsonRpc20Request( 38 | method = "sendTransaction", 39 | params = buildJsonArray { 40 | add(Base58.encodeToString(transaction)) 41 | }, 42 | requestId 43 | ) 44 | 45 | class InvalidTransactionsException(val valid: BooleanArray, message: String? = null, cause: Throwable? = null) : RuntimeException(message, cause) 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/solanakotlincomposescaffold/viewmodel/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.solanakotlincomposescaffold.viewmodel 2 | 3 | import androidx.core.net.toUri 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.example.solanakotlincomposescaffold.BuildConfig 7 | import com.example.solanakotlincomposescaffold.usecase.AccountBalanceUseCase 8 | import com.example.solanakotlincomposescaffold.usecase.ConfirmTransactionUseCase 9 | import com.example.solanakotlincomposescaffold.usecase.Connected 10 | import com.example.solanakotlincomposescaffold.usecase.MemoTransactionUseCase 11 | import com.example.solanakotlincomposescaffold.usecase.PersistenceUseCase 12 | import com.example.solanakotlincomposescaffold.usecase.RequestAirdropUseCase 13 | import com.funkatronics.encoders.Base58 14 | import com.solana.publickey.SolanaPublicKey 15 | import com.solana.mobilewalletadapter.clientlib.ActivityResultSender 16 | import com.solana.mobilewalletadapter.clientlib.MobileWalletAdapter 17 | import com.solana.mobilewalletadapter.clientlib.TransactionResult 18 | import com.solana.mobilewalletadapter.clientlib.successPayload 19 | import dagger.hilt.android.lifecycle.HiltViewModel 20 | import kotlinx.coroutines.Dispatchers 21 | import kotlinx.coroutines.flow.MutableStateFlow 22 | import kotlinx.coroutines.flow.StateFlow 23 | import kotlinx.coroutines.flow.update 24 | import kotlinx.coroutines.launch 25 | import javax.inject.Inject 26 | 27 | data class MainViewState( 28 | val isLoading: Boolean = false, 29 | val canTransact: Boolean = false, 30 | val solBalance: Double = 0.0, 31 | val userAddress: String = "", 32 | val userLabel: String = "", 33 | val walletFound: Boolean = true, 34 | val memoTxSignature: String? = null, 35 | val snackbarMessage: String? = null 36 | ) 37 | 38 | @HiltViewModel 39 | class MainViewModel @Inject constructor( 40 | private val walletAdapter: MobileWalletAdapter, 41 | private val persistenceUseCase: PersistenceUseCase 42 | ): ViewModel() { 43 | 44 | private val rpcUri = BuildConfig.RPC_URI.toUri() 45 | 46 | private fun MainViewState.updateViewState() { 47 | _state.update { this } 48 | } 49 | 50 | private val _state = MutableStateFlow(MainViewState()) 51 | 52 | val viewState: StateFlow 53 | get() = _state 54 | 55 | fun loadConnection() { 56 | val persistedConn = persistenceUseCase.getWalletConnection() 57 | 58 | if (persistedConn is Connected) { 59 | _state.value.copy( 60 | isLoading = true, 61 | canTransact = true, 62 | userAddress = persistedConn.publicKey.base58(), 63 | userLabel = persistedConn.accountLabel, 64 | ).updateViewState() 65 | 66 | getBalance(persistedConn.publicKey) 67 | 68 | _state.value.copy( 69 | isLoading = false, 70 | // TODO: Move all Snackbar message strings into resources 71 | snackbarMessage = "✅ | Successfully auto-connected to: \n" + persistedConn.publicKey.base58() + "." 72 | ).updateViewState() 73 | 74 | walletAdapter.authToken = persistedConn.authToken 75 | } 76 | } 77 | 78 | fun connect(sender: ActivityResultSender) { 79 | viewModelScope.launch { 80 | when (val result = walletAdapter.connect(sender)) { 81 | is TransactionResult.Success -> { 82 | val currentConn = Connected( 83 | SolanaPublicKey(result.authResult.publicKey), 84 | result.authResult.accountLabel ?: "", 85 | result.authResult.authToken 86 | ) 87 | 88 | persistenceUseCase.persistConnection( 89 | currentConn.publicKey, 90 | currentConn.accountLabel, 91 | currentConn.authToken 92 | ) 93 | 94 | _state.value.copy( 95 | isLoading = true, 96 | userAddress = currentConn.publicKey.base58(), 97 | userLabel = currentConn.accountLabel 98 | ).updateViewState() 99 | 100 | getBalance(currentConn.publicKey) 101 | 102 | _state.value.copy( 103 | isLoading = false, 104 | canTransact = true, 105 | snackbarMessage = "✅ | Successfully connected to: \n" + currentConn.publicKey.base58() + "." 106 | ).updateViewState() 107 | } 108 | 109 | is TransactionResult.NoWalletFound -> { 110 | _state.value.copy( 111 | walletFound = false, 112 | snackbarMessage = "❌ | No wallet found." 113 | ).updateViewState() 114 | 115 | } 116 | 117 | is TransactionResult.Failure -> { 118 | _state.value.copy( 119 | isLoading = false, 120 | canTransact = false, 121 | userAddress = "", 122 | userLabel = "", 123 | snackbarMessage = "❌ | Failed connecting to wallet: " + result.e.message 124 | ).updateViewState() 125 | } 126 | } 127 | } 128 | } 129 | 130 | fun signMessage(sender: ActivityResultSender, message: String) { 131 | viewModelScope.launch { 132 | val result = walletAdapter.transact(sender) { 133 | signMessagesDetached(arrayOf(message.toByteArray()), arrayOf((it.accounts.first().publicKey))) 134 | } 135 | 136 | _state.value = when (result) { 137 | is TransactionResult.Success -> { 138 | val signatureBytes = result.successPayload?.messages?.first()?.signatures?.first() 139 | _state.value.copy( 140 | snackbarMessage = signatureBytes?.let { 141 | "✅ | Message signed: ${Base58.encodeToString(it)}" 142 | } ?: "❌ | Incorrect payload returned" 143 | ) 144 | } 145 | is TransactionResult.NoWalletFound -> { 146 | _state.value.copy(snackbarMessage = "❌ | No wallet found") 147 | } 148 | is TransactionResult.Failure -> { 149 | _state.value.copy(snackbarMessage = "❌ | Message signing failed: ${result.e.message}") 150 | } 151 | }.also { it.updateViewState() } 152 | } 153 | } 154 | 155 | fun signTransaction(sender: ActivityResultSender ) { 156 | viewModelScope.launch { 157 | val result = walletAdapter.transact(sender) { authResult -> 158 | val account = SolanaPublicKey(authResult.accounts.first().publicKey) 159 | val memoTx = MemoTransactionUseCase(rpcUri, account, "Hello Solana!"); 160 | signTransactions(arrayOf( 161 | memoTx.serialize(), 162 | )); 163 | } 164 | 165 | _state.value = when (result) { 166 | is TransactionResult.Success -> { 167 | val signedTxBytes = result.successPayload?.signedPayloads?.first() 168 | signedTxBytes?.let { 169 | println("Memo publish signature: " + Base58.encodeToString(signedTxBytes)) 170 | } 171 | _state.value.copy( 172 | snackbarMessage = (signedTxBytes?.let { 173 | "✅ | Transaction signed: ${Base58.encodeToString(it)}" 174 | } ?: "❌ | Incorrect payload returned"), 175 | ) 176 | } 177 | is TransactionResult.NoWalletFound -> { 178 | _state.value.copy(snackbarMessage = "❌ | No wallet found") 179 | } 180 | is TransactionResult.Failure -> { 181 | _state.value.copy(snackbarMessage = "❌ | Transaction failed to submit: ${result.e.message}") 182 | } 183 | }.also { it.updateViewState() } 184 | } 185 | } 186 | 187 | fun publishMemo(sender: ActivityResultSender, memoText: String) { 188 | viewModelScope.launch { 189 | val result = walletAdapter.transact(sender) { authResult -> 190 | val account = SolanaPublicKey(authResult.accounts.first().publicKey) 191 | val memoTx = MemoTransactionUseCase(rpcUri, account, memoText); 192 | signAndSendTransactions(arrayOf(memoTx.serialize())); 193 | } 194 | 195 | _state.value = when (result) { 196 | is TransactionResult.Success -> { 197 | val signatureBytes = result.successPayload?.signatures?.first() 198 | signatureBytes?.let { 199 | println("Memo publish signature: " + Base58.encodeToString(signatureBytes)) 200 | _state.value.copy( 201 | snackbarMessage = "✅ | Transaction submitted: ${Base58.encodeToString(it)}", 202 | memoTxSignature = Base58.encodeToString(it) 203 | ) 204 | } ?: _state.value.copy( 205 | snackbarMessage = "❌ | Incorrect payload returned" 206 | ) 207 | } 208 | is TransactionResult.NoWalletFound -> { 209 | _state.value.copy(snackbarMessage = "❌ | No wallet found") 210 | } 211 | is TransactionResult.Failure -> { 212 | _state.value.copy(snackbarMessage = "❌ | Transaction failed to submit: ${result.e.message}") 213 | } 214 | }.also { it.updateViewState() } 215 | } 216 | } 217 | 218 | fun getBalance(account: SolanaPublicKey) { 219 | viewModelScope.launch(Dispatchers.IO) { 220 | try { 221 | val result = 222 | AccountBalanceUseCase(rpcUri, account) 223 | 224 | _state.value.copy( 225 | solBalance = result/1000000000.0 226 | ).updateViewState() 227 | } catch (e: Exception) { 228 | _state.value.copy( 229 | snackbarMessage = "❌ | Failed fetching account balance." 230 | ).updateViewState() 231 | } 232 | } 233 | } 234 | 235 | fun requestAirdrop(account : SolanaPublicKey) { 236 | viewModelScope.launch(Dispatchers.IO) { 237 | try { 238 | val signature = RequestAirdropUseCase(rpcUri, account, 10000) 239 | 240 | if (ConfirmTransactionUseCase(rpcUri, signature)) { 241 | _state.value.copy( 242 | snackbarMessage = "✅ | Airdrop request succeeded!" 243 | ).updateViewState() 244 | } 245 | 246 | getBalance(account) 247 | } catch (e: RequestAirdropUseCase.AirdropFailedException) { 248 | _state.value.copy( 249 | snackbarMessage = "❌ | Airdrop request failed: " + e.message 250 | ).updateViewState() 251 | } catch (e: ConfirmTransactionUseCase.SignatureStatusException) { 252 | _state.value.copy( 253 | snackbarMessage = "❌ | Signature status exception: " + e.message 254 | ).updateViewState() 255 | } 256 | } 257 | } 258 | 259 | fun disconnect() { 260 | viewModelScope.launch { 261 | val conn = persistenceUseCase.getWalletConnection() 262 | if (conn is Connected) { 263 | persistenceUseCase.clearConnection() 264 | 265 | MainViewState().copy( 266 | snackbarMessage = "✅ | Disconnected from wallet." 267 | ).updateViewState() 268 | } 269 | } 270 | } 271 | 272 | fun clearSnackBar() { 273 | _state.value.copy( 274 | snackbarMessage = null 275 | ).updateViewState() 276 | } 277 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-mobile/solana-kotlin-compose-scaffold/f47072be1787baf511adc668b6d36f7c3dab1080/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Solana Kotlin Compose Scaffold 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |