diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..fa3e10db4 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,177 @@ +name: Android SDK CI + +on: + pull_request: + branches: + - main + paths: + - 'apps/androidkit/**' + - '.github/workflows/android.yml' + +jobs: + # Build JavaScript bundles for WebView and QuickJS + build-js: + name: Build JS Bundles + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read .nvmrc + run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_ENV + + - uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '${{ env.NVMRC }}' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages including @ton/walletkit + run: pnpm build + + - name: Build JS bundles for Android + working-directory: apps/androidkit + run: pnpm run build:all + + - name: Upload JS bundles + uses: actions/upload-artifact@v4 + with: + name: js-bundles + path: | + apps/androidkit/dist-android/ + apps/androidkit/dist-android-quickjs/ + retention-days: 1 + + # Code formatting check with Spotless + spotless: + name: Spotless Check + runs-on: ubuntu-latest + needs: build-js + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Download JS bundles + uses: actions/download-artifact@v4 + with: + name: js-bundles + path: apps/androidkit/ + + - name: Grant execute permission for gradlew + working-directory: apps/androidkit/TONWalletKit-Android + run: chmod +x gradlew + + - name: Run Spotless Check + working-directory: apps/androidkit/TONWalletKit-Android + run: ./gradlew spotlessCheck + + # Build and test SDK + test-sdk: + name: Test & Build SDK + runs-on: ubuntu-latest + needs: build-js + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Download JS bundles + uses: actions/download-artifact@v4 + with: + name: js-bundles + path: apps/androidkit/ + + - name: Grant execute permission for gradlew + working-directory: apps/androidkit/TONWalletKit-Android + run: chmod +x gradlew + + - name: Run unit tests + working-directory: apps/androidkit/TONWalletKit-Android + run: ./gradlew :bridge:testWebviewReleaseUnitTest + + - name: Build WebView variant + working-directory: apps/androidkit/TONWalletKit-Android + run: ./gradlew buildWebview + + - name: Upload WebView AAR + uses: actions/upload-artifact@v4 + with: + name: bridge-webview-release + path: apps/androidkit/TONWalletKit-Android/bridge/build/outputs/aar/bridge-webview-release.aar + retention-days: 7 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: apps/androidkit/TONWalletKit-Android/bridge/build/test-results/ + retention-days: 7 + + # Build Demo App + build-demo: + name: Build Demo App + runs-on: ubuntu-latest + needs: build-js + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Download JS bundles + uses: actions/download-artifact@v4 + with: + name: js-bundles + path: apps/androidkit/ + + - name: Grant execute permission for gradlew (SDK) + working-directory: apps/androidkit/TONWalletKit-Android + run: chmod +x gradlew + + - name: Build SDK for Demo + working-directory: apps/androidkit/TONWalletKit-Android + run: ./gradlew buildWebview + + - name: Copy AAR to Demo app libs + run: | + mkdir -p apps/androidkit/AndroidDemo/app/libs + cp apps/androidkit/TONWalletKit-Android/bridge/build/outputs/aar/bridge-webview-release.aar apps/androidkit/AndroidDemo/app/libs/bridge-release.aar + + - name: Grant execute permission for gradlew (Demo) + working-directory: apps/androidkit/AndroidDemo + run: chmod +x gradlew + + - name: Build Demo App + working-directory: apps/androidkit/AndroidDemo + run: ./gradlew assembleDebug + + - name: Upload Demo APK + uses: actions/upload-artifact@v4 + with: + name: demo-app-debug + path: apps/androidkit/AndroidDemo/app/build/outputs/apk/debug/app-debug.apk + retention-days: 7 diff --git a/.gitignore b/.gitignore index 15acc0d93..4d8aac14e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .env .DS_Store dist +dist-android .idea .turbo apps/demo-wallet/dist-extension.pem @@ -12,4 +13,17 @@ reports *-results *-report +apps/TONWalletApp/Packages/TONWalletKit/.swiftpm/xcode/xcuserdata/ +apps/TONWalletApp/Packages/TONWalletKit/Sources/TONWalletKit/Resources/JS/walletkit-ios-bridge.mjs +apps/TONWalletApp/Packages/TONWalletKit/Sources/TONWalletKit/Resources/JS/walletkit-ios-bridge.mjs.map +apps/TONWalletApp/Packages/TONWalletKit/Sources/TONWalletKit/Resources/JS/walletkit-ios-bridge.umd.js +apps/TONWalletApp/Packages/TONWalletKit/Sources/TONWalletKit/Resources/JS/walletkit-ios-bridge.umd.js.map +apps/TONWalletApp/TONWalletApp.xcodeproj/project.xcworkspace/xcuserdata/ +apps/TONWalletApp/TONWalletApp.xcodeproj/xcuserdata/ +apps/TONWalletApp/TONWalletApp.xcodeproj/xcuserdata/ + +# Android QuickJS build artifacts +apps/androidkit/AndroidDemo/bridge/.cxx/ +apps/androidkit/dist-android-quickjs/ +node.log packages/walletkit-ios-bridge/build/* diff --git a/apps/androidkit/.gitignore b/apps/androidkit/.gitignore new file mode 100644 index 000000000..03a1ce06c --- /dev/null +++ b/apps/androidkit/.gitignore @@ -0,0 +1,53 @@ +# Build outputs (JavaScript bundles) +dist-android/ +dist-android-quickjs/ + +# Dependencies +node_modules/ + +# macOS +.DS_Store + +# Turbo cache +.turbo/ + +# Local Maven repository (generated artifacts) +local-maven-repo/ + +# Android Studio / Gradle (project level) +.gradle/ +build/ +.cxx/ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/misc.xml +.idea/.gitignore +.idea/caches +.idea/modules.xml +.idea/navEditor.xml +.idea/deploymentTargetDropDown.xml + +# Local configuration files +local.properties +gradle.properties +# But include gradle.properties for the SDK (contains required build config) +!TONWalletKit-Android/gradle.properties + +# AAR files in libs folders (build artifacts) +**/libs/*.aar + +# Temporary documentation files (can be regenerated) +JITPACK_QUICKSTART.md +PUBLISHING_GUIDE.md +gradle.properties.template + +# Build logs +*.log +node.log diff --git a/apps/androidkit/AndroidDemo/.gitignore b/apps/androidkit/AndroidDemo/.gitignore new file mode 100644 index 000000000..15f4b3525 --- /dev/null +++ b/apps/androidkit/AndroidDemo/.gitignore @@ -0,0 +1,10 @@ +# Gradle and build outputs +.gradle/ +build/ +app/build/ +.idea/ +local.properties +*.iml +.DS_Store +captures/ +.kotlin diff --git a/apps/androidkit/AndroidDemo/META-INF/MANIFEST.MF b/apps/androidkit/AndroidDemo/META-INF/MANIFEST.MF new file mode 100644 index 000000000..9db312838 --- /dev/null +++ b/apps/androidkit/AndroidDemo/META-INF/MANIFEST.MF @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +Implementation-Title: Gradle +Implementation-Version: 8.7 + diff --git a/apps/androidkit/AndroidDemo/README.md b/apps/androidkit/AndroidDemo/README.md new file mode 100644 index 000000000..129bddddf --- /dev/null +++ b/apps/androidkit/AndroidDemo/README.md @@ -0,0 +1,38 @@ +# Android WalletKit Demo + +Demo app for TON WalletKit SDK. + +## Setup + +Build SDK first: + +```bash +cd ../TONWalletKit-Android +./gradlew buildAndCopyWebviewToDemo +``` + +Then open this project in Android Studio and run. + +## Features + +- Wallet creation and import +- TON Connect integration +- Transaction signing +- Balance display and auto-refresh + +## Engine Selection + +Edit `WalletKitDemoApp.kt`: + +```kotlin +val defaultEngineKind = WalletKitEngineKind.WEBVIEW // Recommended +``` + +For QuickJS: use full variant and uncomment OkHttp in `app/build.gradle.kts`. + +## Structure + +- `ui/` - Compose screens +- `viewmodel/` - State management +- `storage/` - Secure wallet storage +- `libs/bridge-release.aar` - SDK (copied by build task) diff --git a/apps/androidkit/AndroidDemo/app/build.gradle.kts b/apps/androidkit/AndroidDemo/app/build.gradle.kts new file mode 100644 index 000000000..4583ec30e --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/build.gradle.kts @@ -0,0 +1,72 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinCompose) +} + +android { + namespace = "io.ton.walletkit.demo" + compileSdk = 36 + + defaultConfig { + applicationId = "io.ton.walletkit.demo" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation(libs.androidxCoreKtx) + implementation(libs.androidxAppcompat) + implementation(libs.googleMaterial) + implementation(libs.androidxActivityKtx) + implementation(libs.androidxConstraintLayout) + implementation(libs.androidxActivityCompose) + implementation(platform(libs.androidxComposeBom)) + implementation(libs.androidxComposeUi) + implementation(libs.androidxComposeMaterial3) + implementation(libs.androidxComposeMaterialIconsExtended) + implementation(libs.androidxComposeUiToolingPreview) + debugImplementation(libs.androidxComposeUiTooling) + implementation(libs.androidxLifecycleRuntimeKtx) + implementation(libs.androidxLifecycleViewmodelCompose) + implementation(libs.kotlinxCoroutinesAndroid) + implementation(libs.androidxSecurityCrypto) + + // TONWalletKit SDK - AAR file + // Build and copy with: ./gradlew buildAndCopyWebviewToDemo (or buildAndCopyFullToDemo) + implementation(files("libs/bridge-release.aar")) + + // Required transitive dependencies (must be declared manually with AAR files) + implementation(libs.androidxWebkit) + implementation(libs.androidxDatastorePreferences) + implementation(libs.kotlinxSerializationJson) + // OkHttp only needed if using full variant: + // implementation(libs.okhttp) +} diff --git a/apps/androidkit/AndroidDemo/app/proguard-rules.pro b/apps/androidkit/AndroidDemo/app/proguard-rules.pro new file mode 100644 index 000000000..5183b2bed --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/proguard-rules.pro @@ -0,0 +1 @@ +# Keep default rules \ No newline at end of file diff --git a/apps/androidkit/AndroidDemo/app/src/main/AndroidManifest.xml b/apps/androidkit/AndroidDemo/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..00223c51b --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/apps/androidkit/AndroidDemo/app/src/main/assets/.gitignore b/apps/androidkit/AndroidDemo/app/src/main/assets/.gitignore new file mode 100644 index 000000000..480610157 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/assets/.gitignore @@ -0,0 +1 @@ +walletkit/ diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/PerformanceBenchmarkViewModel.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/PerformanceBenchmarkViewModel.kt new file mode 100644 index 000000000..c435a667f --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/PerformanceBenchmarkViewModel.kt @@ -0,0 +1,192 @@ +package io.ton.walletkit.demo + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.ton.walletkit.demo.storage.DemoAppStorage +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.WalletKitEngineKind +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class BenchmarkState( + val isRunning: Boolean = false, + val currentEngine: WalletKitEngineKind? = null, + val currentRun: Int = 0, + val totalRuns: Int = 0, + val status: String = "Ready to benchmark", +) + +class PerformanceBenchmarkViewModel( + private val app: WalletKitDemoApp, + private val storage: DemoAppStorage, +) : ViewModel() { + + private val _state = MutableStateFlow(BenchmarkState()) + val state: StateFlow = _state.asStateFlow() + + // Use a valid 24-word mnemonic (same as DEMO_MNEMONIC in WalletKitViewModel) + private val testMnemonic = + listOf( + "canvas", + "puzzle", + "ski", + "divide", + "crime", + "arrow", + "object", + "canvas", + "point", + "cover", + "method", + "bargain", + "siren", + "bean", + "shrimp", + "found", + "gravity", + "vivid", + "pelican", + "replace", + "tuition", + "screen", + "orange", + "album", + ) + + fun runBenchmark(engineKind: WalletKitEngineKind, runs: Int) { + if (_state.value.isRunning) return + + viewModelScope.launch { + _state.update { + it.copy( + isRunning = true, + currentEngine = engineKind, + currentRun = 0, + totalRuns = runs, + status = "Starting ${engineKind.name} benchmark...", + ) + } + + repeat(runs) { runIndex -> + val runNumber = runIndex + 1 + _state.update { + it.copy( + currentRun = runNumber, + status = "Running ${engineKind.name} #$runNumber/$runs", + ) + } + + runSingleBenchmark(engineKind, runNumber) + + // Give system a moment to stabilize between runs + kotlinx.coroutines.delay(500) + } + + _state.update { + it.copy( + isRunning = false, + currentEngine = null, + currentRun = 0, + totalRuns = 0, + status = "Completed ${engineKind.name} benchmark ($runs runs)", + ) + } + } + } + + private suspend fun runSingleBenchmark(engineKind: WalletKitEngineKind, runNumber: Int) { + PerformanceCollector.startMeasurement(engineKind, runNumber) + + var engine: WalletKitEngine? = null + try { + // 1. Engine Creation + val (createdEngine, engineCreationTime) = + measureTime { + app.obtainEngine(engineKind) + } + engine = createdEngine + PerformanceCollector.recordEngineCreation(engineCreationTime) + Log.d(TAG, "[$engineKind #$runNumber] Engine created in ${engineCreationTime}ms") + + // 2. Init + val config = + WalletKitBridgeConfig( + network = "mainnet", + ) + val (initResult, initTime) = + measureTime { + engine.init(config) + } + PerformanceCollector.recordInit(initTime) + Log.d(TAG, "[$engineKind #$runNumber] Init completed in ${initTime}ms") + + // 3. Add Wallet + val (wallet, addWalletTime) = + measureTime { + engine.addWalletFromMnemonic( + words = testMnemonic, + version = "v5r1", + network = "mainnet", + ) + } + PerformanceCollector.recordAddWallet(addWalletTime) + Log.d(TAG, "[$engineKind #$runNumber] Add wallet completed in ${addWalletTime}ms") + + // 4. Get Wallets (replaces getAccount) + val (wallets, getWalletsTime) = + measureTime { + engine.getWallets() + } + PerformanceCollector.recordGetAccount(getWalletsTime) + Log.d(TAG, "[$engineKind #$runNumber] Get wallets completed in ${getWalletsTime}ms") + + val firstWallet = wallets.firstOrNull() + if (firstWallet != null) { + // 5. Get Wallet State (includes balance) + val (state, getStateTime) = + measureTime { + engine.getWalletState(firstWallet.address) + } + PerformanceCollector.recordGetBalance(getStateTime) + Log.d(TAG, "[$engineKind #$runNumber] Get state completed in ${getStateTime}ms") + } else { + Log.w(TAG, "[$engineKind #$runNumber] No wallets found, skipping get state") + } + + Log.d(TAG, "[$engineKind #$runNumber] Benchmark completed successfully") + } catch (e: Exception) { + Log.e(TAG, "[$engineKind #$runNumber] Benchmark failed: ${e.message}", e) + } finally { + // Clean up + try { + engine?.destroy() + } catch (e: Exception) { + Log.e(TAG, "[$engineKind #$runNumber] Failed to destroy engine", e) + } + + PerformanceCollector.finishMeasurement() + } + } + + companion object { + private const val TAG = "PerformanceBenchmark" + } +} + +class PerformanceBenchmarkViewModelFactory( + private val app: WalletKitDemoApp, + private val storage: DemoAppStorage, +) : androidx.lifecycle.ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PerformanceBenchmarkViewModel::class.java)) { + return PerformanceBenchmarkViewModel(app, storage) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/PerformanceMetrics.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/PerformanceMetrics.kt new file mode 100644 index 000000000..06b619027 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/PerformanceMetrics.kt @@ -0,0 +1,160 @@ +package io.ton.walletkit.demo + +import android.util.Log +import io.ton.walletkit.presentation.WalletKitEngineKind +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Performance metrics for comparing QuickJS vs WebView engines. + */ +data class PerformanceMetrics( + val engineKind: WalletKitEngineKind, + val runNumber: Int, + val engineCreationMs: Long = 0, + val initMs: Long = 0, + val addWalletMs: Long = 0, + val getAccountMs: Long = 0, + val getBalanceMs: Long = 0, + val signTransactionMs: Long = 0, + val totalStartupMs: Long = 0, +) { + fun toCsvRow(): String = "${engineKind.name},$runNumber,$engineCreationMs,$initMs,$addWalletMs,$getAccountMs,$getBalanceMs,$signTransactionMs,$totalStartupMs" + + companion object { + fun csvHeader(): String = "Engine,Run,EngineCreation(ms),Init(ms),AddWallet(ms),GetAccount(ms),GetBalance(ms),SignTransaction(ms),TotalStartup(ms)" + } +} + +/** + * Collects and displays performance metrics for engine comparison. + */ +object PerformanceCollector { + private const val TAG = "PerformanceCollector" + + private val _metrics = MutableStateFlow>(emptyList()) + val metrics: StateFlow> = _metrics.asStateFlow() + + private var currentMetric: PerformanceMetrics? = null + private var startupStartTime: Long = 0 + + fun startMeasurement(engineKind: WalletKitEngineKind, runNumber: Int) { + startupStartTime = System.currentTimeMillis() + currentMetric = PerformanceMetrics(engineKind = engineKind, runNumber = runNumber) + Log.d(TAG, "Started measurement for ${engineKind.name} run #$runNumber") + } + + fun recordEngineCreation(durationMs: Long) { + currentMetric = currentMetric?.copy(engineCreationMs = durationMs) + Log.d(TAG, "Engine creation: ${durationMs}ms") + } + + fun recordInit(durationMs: Long) { + currentMetric = currentMetric?.copy(initMs = durationMs) + Log.d(TAG, "Init: ${durationMs}ms") + } + + fun recordAddWallet(durationMs: Long) { + currentMetric = currentMetric?.copy(addWalletMs = durationMs) + Log.d(TAG, "Add wallet: ${durationMs}ms") + } + + fun recordGetAccount(durationMs: Long) { + currentMetric = currentMetric?.copy(getAccountMs = durationMs) + Log.d(TAG, "Get account: ${durationMs}ms") + } + + fun recordGetBalance(durationMs: Long) { + currentMetric = currentMetric?.copy(getBalanceMs = durationMs) + Log.d(TAG, "Get balance: ${durationMs}ms") + } + + fun recordSignTransaction(durationMs: Long) { + currentMetric = currentMetric?.copy(signTransactionMs = durationMs) + Log.d(TAG, "Sign transaction: ${durationMs}ms") + } + + fun finishMeasurement() { + val totalMs = System.currentTimeMillis() - startupStartTime + val metric = currentMetric?.copy(totalStartupMs = totalMs) ?: return + + _metrics.update { it + metric } + Log.d(TAG, "Finished measurement for ${metric.engineKind.name} run #${metric.runNumber}") + Log.d(TAG, "Total startup: ${totalMs}ms") + + currentMetric = null + } + + fun getStats(): String { + val metricsMap = _metrics.value.groupBy { it.engineKind } + + val sb = StringBuilder() + sb.appendLine("=== Performance Comparison ===\n") + + metricsMap.forEach { (engine, runs) -> + sb.appendLine("${engine.name} (${runs.size} runs):") + sb.appendLine(" Engine Creation: ${runs.map { it.engineCreationMs }.average().formatMs()} (${runs.map { it.engineCreationMs }.minOrNull()}ms - ${runs.map { it.engineCreationMs }.maxOrNull()}ms)") + sb.appendLine(" Init: ${runs.map { it.initMs }.average().formatMs()} (${runs.map { it.initMs }.minOrNull()}ms - ${runs.map { it.initMs }.maxOrNull()}ms)") + sb.appendLine(" Add Wallet: ${runs.map { it.addWalletMs }.average().formatMs()} (${runs.map { it.addWalletMs }.minOrNull()}ms - ${runs.map { it.addWalletMs }.maxOrNull()}ms)") + sb.appendLine(" Get Account: ${runs.map { it.getAccountMs }.average().formatMs()} (${runs.map { it.getAccountMs }.minOrNull()}ms - ${runs.map { it.getAccountMs }.maxOrNull()}ms)") + sb.appendLine(" Get Balance: ${runs.map { it.getBalanceMs }.average().formatMs()} (${runs.map { it.getBalanceMs }.minOrNull()}ms - ${runs.map { it.getBalanceMs }.maxOrNull()}ms)") + sb.appendLine(" Total Startup: ${runs.map { it.totalStartupMs }.average().formatMs()} (${runs.map { it.totalStartupMs }.minOrNull()}ms - ${runs.map { it.totalStartupMs }.maxOrNull()}ms)") + sb.appendLine() + } + + // Comparison + if (metricsMap.size == 2) { + val quickjs = metricsMap[WalletKitEngineKind.QUICKJS] + val webview = metricsMap[WalletKitEngineKind.WEBVIEW] + + if (quickjs != null && webview != null) { + sb.appendLine("=== Speed Comparison (QuickJS vs WebView) ===") + sb.appendLine("Engine Creation: ${compareMetrics(quickjs.map { it.engineCreationMs }, webview.map { it.engineCreationMs })}") + sb.appendLine("Init: ${compareMetrics(quickjs.map { it.initMs }, webview.map { it.initMs })}") + sb.appendLine("Add Wallet: ${compareMetrics(quickjs.map { it.addWalletMs }, webview.map { it.addWalletMs })}") + sb.appendLine("Get Account: ${compareMetrics(quickjs.map { it.getAccountMs }, webview.map { it.getAccountMs })}") + sb.appendLine("Get Balance: ${compareMetrics(quickjs.map { it.getBalanceMs }, webview.map { it.getBalanceMs })}") + sb.appendLine("Total Startup: ${compareMetrics(quickjs.map { it.totalStartupMs }, webview.map { it.totalStartupMs })}") + } + } + + return sb.toString() + } + + fun exportCsv(): String { + val sb = StringBuilder() + sb.appendLine(PerformanceMetrics.csvHeader()) + _metrics.value.forEach { metric -> + sb.appendLine(metric.toCsvRow()) + } + return sb.toString() + } + + fun clear() { + _metrics.value = emptyList() + currentMetric = null + Log.d(TAG, "Cleared all metrics") + } + + private fun Double.formatMs(): String = "%.1fms".format(this) + + private fun compareMetrics(quickjs: List, webview: List): String { + val qAvg = quickjs.average() + val wAvg = webview.average() + val diff = ((qAvg - wAvg) / wAvg * 100).toInt() + val faster = if (qAvg < wAvg) "QuickJS faster" else "WebView faster" + return "$faster by ${kotlin.math.abs(diff)}% (${qAvg.formatMs()} vs ${wAvg.formatMs()})" + } +} + +/** + * Helper to measure execution time of a suspend function. + */ +suspend inline fun measureTime(block: () -> T): Pair { + val start = System.currentTimeMillis() + val result = block() + val duration = System.currentTimeMillis() - start + return result to duration +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/WalletKitDemoApp.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/WalletKitDemoApp.kt new file mode 100644 index 000000000..12cff449e --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/WalletKitDemoApp.kt @@ -0,0 +1,31 @@ +package io.ton.walletkit.demo + +import android.app.Application +import io.ton.walletkit.demo.storage.DemoAppStorage +import io.ton.walletkit.demo.storage.SecureDemoAppStorage +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.WalletKitEngineFactory +import io.ton.walletkit.presentation.WalletKitEngineKind + +class WalletKitDemoApp : Application() { + val defaultEngineKind: WalletKitEngineKind = WalletKitEngineKind.WEBVIEW + + fun obtainEngine(kind: WalletKitEngineKind = defaultEngineKind): WalletKitEngine { + // Use factory to avoid compile-time dependency on engine implementations + // This works with both webview-only and full SDK variants + return WalletKitEngineFactory.create(this, kind) + } + + /** + * Check if a specific engine kind is available in the current SDK variant. + */ + fun isEngineAvailable(kind: WalletKitEngineKind): Boolean = WalletKitEngineFactory.isAvailable(kind) + + /** + * Demo app storage for wallet mnemonics, metadata, and user preferences. + * This is separate from the SDK's internal bridge storage. + */ + val storage: DemoAppStorage by lazy { + SecureDemoAppStorage(this) + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/WalletKitViewModel.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/WalletKitViewModel.kt new file mode 100644 index 000000000..3ca7c1ca5 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/WalletKitViewModel.kt @@ -0,0 +1,1725 @@ +package io.ton.walletkit.demo + +import android.os.Build +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.ton.walletkit.demo.cache.TransactionCache +import io.ton.walletkit.demo.model.ConnectPermissionUi +import io.ton.walletkit.demo.model.ConnectRequestUi +import io.ton.walletkit.demo.model.PendingWalletRecord +import io.ton.walletkit.demo.model.SessionSummary +import io.ton.walletkit.demo.model.SignDataRequestUi +import io.ton.walletkit.demo.model.TonNetwork +import io.ton.walletkit.demo.model.TransactionDetailUi +import io.ton.walletkit.demo.model.TransactionMessageUi +import io.ton.walletkit.demo.model.TransactionRequestUi +import io.ton.walletkit.demo.model.WalletMetadata +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.state.SheetState +import io.ton.walletkit.demo.state.WalletUiState +import io.ton.walletkit.demo.storage.DemoAppStorage +import io.ton.walletkit.demo.storage.UserPreferences +import io.ton.walletkit.demo.storage.WalletRecord +import io.ton.walletkit.demo.util.TransactionDiffUtil +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.TransactionType +import io.ton.walletkit.domain.model.WalletAccount +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.event.WalletKitEvent +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.io.Closeable +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.Locale +import java.util.TimeZone +import kotlin.collections.ArrayDeque +import kotlin.collections.firstOrNull + +class WalletKitViewModel( + private val engine: WalletKitEngine, + private val storage: DemoAppStorage, +) : ViewModel() { + + private val _state = MutableStateFlow( + WalletUiState( + status = "Initializing WalletKit…", + ), + ) + val state: StateFlow = _state.asStateFlow() + + private var bridgeListener: Closeable? = null + private var balanceJob: Job? = null + private var currentNetwork: TonNetwork = DEFAULT_NETWORK + + private val walletMetadata = mutableMapOf() + private val pendingWallets = ArrayDeque() + + // Transaction cache for efficient list updates + private val transactionCache = TransactionCache() + + init { + viewModelScope.launch { bootstrap() } + } + + private suspend fun bootstrap() { + _state.update { it.copy(status = "Initializing WalletKit…", error = null) } + + // Load user preferences (including active wallet address) + val userPrefs = storage.loadUserPreferences() + val savedActiveWallet = userPrefs?.activeWalletAddress + Log.d(LOG_TAG, "Loaded saved active wallet: $savedActiveWallet") + + // Initialize bridge with default network (wallets will be auto-restored by bridge) + currentNetwork = DEFAULT_NETWORK + + val initResult = runCatching { + engine.init( + WalletKitBridgeConfig( + network = currentNetwork.asBridgeValue(), + tonClientEndpoint = networkEndpoints(currentNetwork).tonClientEndpoint, + tonApiUrl = networkEndpoints(currentNetwork).tonApiUrl, + bridgeUrl = networkEndpoints(currentNetwork).bridgeUrl, + bridgeName = networkEndpoints(currentNetwork).bridgeName, + appName = "Wallet", // Use registered wallet name for TonConnect compatibility + // Storage is always persistent - no allowMemoryStorage parameter + ), + ) + } + + if (initResult.isFailure) { + _state.update { + it.copy( + status = "WalletKit failed to initialize", + error = initResult.exceptionOrNull()?.message ?: "Initialization error", + ) + } + return + } + + // Restore wallets from secure storage into the bridge if needed + migrateLegacyWallets() + + // Use typed event handler + bridgeListener = engine.addEventHandler(object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + onBridgeEvent(event) + } + }) + _state.update { it.copy(initialized = true, status = "WalletKit ready", error = null) } + + refreshAll() + + // Restore saved active wallet after wallets are loaded + if (savedActiveWallet != null) { + _state.update { it.copy(activeWalletAddress = savedActiveWallet) } + Log.d(LOG_TAG, "Restored active wallet selection: $savedActiveWallet") + // Fetch transactions for the restored active wallet + refreshTransactions(savedActiveWallet) + } else { + // Fetch transactions for the first wallet if no saved wallet + state.value.activeWalletAddress?.let { address -> + refreshTransactions(address) + } + } + + startBalancePolling() + } + + /** + * Restore wallets that were persisted in secure Android storage. + * Runs on every initialization to ensure wallets survive bridge resets. + */ + private suspend fun migrateLegacyWallets() { + try { + val storedWallets = storage.loadAllWallets() + + if (storedWallets.isEmpty()) { + Log.d(LOG_TAG, "No legacy wallets to migrate") + return + } + + // Check if bridge already has wallets + val bridgeWallets = runCatching { engine.getWallets() }.getOrDefault(emptyList()) + val existingAddresses = bridgeWallets.mapTo(mutableSetOf()) { it.address } + + Log.d( + LOG_TAG, + "Restoring ${storedWallets.size} wallets from secure storage (bridge has ${existingAddresses.size})", + ) + + var migratedCount = 0 + storedWallets.forEach { (address, record) -> + try { + if (record.mnemonic.isEmpty()) { + Log.w(LOG_TAG, "Skipping wallet with empty mnemonic: $address") + return@forEach + } + + val network = TonNetwork.fromBridge(record.network, currentNetwork) + val version = record.version ?: DEFAULT_WALLET_VERSION + + val displayName = record.name ?: defaultWalletName(walletMetadata.size) + + if (existingAddresses.contains(address)) { + Log.d(LOG_TAG, "Wallet already present in bridge, skipping add: $address") + } else { + // Add to bridge (keep data in secure storage for future restores) + engine.addWalletFromMnemonic( + words = record.mnemonic, + name = displayName, + version = version, + network = network.asBridgeValue(), + ) + migratedCount++ + existingAddresses.add(address) + Log.d(LOG_TAG, "Restored wallet: $address (${record.name ?: "unnamed"})") + } + + // Store metadata for UI + walletMetadata[address] = WalletMetadata( + name = displayName, + network = network, + version = version, + ) + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to migrate wallet: $address", e) + } + } + + if (migratedCount > 0) { + Log.d( + LOG_TAG, + "Restoration complete: $migratedCount/${storedWallets.size} wallets added to bridge", + ) + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Migration failed", e) + } + } + + private fun startBalancePolling() { + balanceJob?.cancel() + balanceJob = viewModelScope.launch { + while (true) { + delay(BALANCE_REFRESH_MS) + refreshWallets() + } + } + } + + fun refreshAll() { + viewModelScope.launch { + refreshWallets() + refreshSessions() + } + } + + fun refreshWallets() { + viewModelScope.launch { + _state.update { it.copy(isLoadingWallets = true) } + val summaries = runCatching { loadWalletSummaries() } + summaries.onSuccess { wallets -> + val now = System.currentTimeMillis() + + // Set active wallet based on saved preference or default to first + val activeAddress = state.value.activeWalletAddress + val newActiveAddress = when { + wallets.isEmpty() -> null + // Keep current active wallet if it still exists + activeAddress != null && wallets.any { it.address == activeAddress } -> activeAddress + // Otherwise use first wallet + else -> wallets.firstOrNull()?.address + } + + _state.update { + it.copy( + wallets = wallets, + activeWalletAddress = newActiveAddress, + lastUpdated = now, + error = null, + ) + } + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to load wallets") } + } + _state.update { it.copy(isLoadingWallets = false) } + } + } + + fun refreshSessions() { + viewModelScope.launch { + _state.update { it.copy(isLoadingSessions = true) } + val result = runCatching { engine.listSessions() } + result.onSuccess { sessions -> + Log.d(LOG_TAG, "Loaded ${sessions.size} sessions from bridge") + sessions.forEach { session -> + Log.d( + LOG_TAG, + "Session: id=${session.sessionId}, dApp=${session.dAppName}, " + + "wallet=${session.walletAddress}, url=${session.dAppUrl}, manifest=${session.manifestUrl}, " + + "icon=${session.iconUrl}", + ) + } + val mapped = sessions.mapNotNull { session -> + val sessionUrl = sanitizeUrl(session.dAppUrl) + val sessionManifest = sanitizeUrl(session.manifestUrl) + val sessionIcon = sanitizeUrl(session.iconUrl) + + // Skip sessions with no metadata (appears disconnected) + val appearsDisconnected = sessionUrl == null && sessionManifest == null + if (appearsDisconnected && session.sessionId.isNotBlank()) { + Log.d( + LOG_TAG, + "Bridge returned empty metadata for session ${session.sessionId}; removing stale entry", + ) + viewModelScope.launch { runCatching { engine.disconnectSession(session.sessionId) } } + return@mapNotNull null + } + + val mergedUrl = sessionUrl + val mergedManifest = sessionManifest + val mergedIcon = sessionIcon + SessionSummary( + sessionId = session.sessionId, + dAppName = session.dAppName.ifBlank { "Unknown dApp" }, + walletAddress = session.walletAddress, + dAppUrl = mergedUrl, + manifestUrl = mergedManifest, + iconUrl = mergedIcon, + createdAt = parseTimestamp(session.createdAtIso), + lastActivity = parseTimestamp(session.lastActivityIso), + ) + } + val finalSessions = mapped + finalSessions.forEach { summary -> + Log.d( + LOG_TAG, + "Mapped session summary: id=${summary.sessionId}, dApp=${summary.dAppName}, " + + "url=${summary.dAppUrl}, manifest=${summary.manifestUrl}, icon=${summary.iconUrl}", + ) + } + _state.update { it.copy(sessions = finalSessions, error = null) } + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to load sessions") } + } + _state.update { it.copy(isLoadingSessions = false) } + } + } + + fun openAddWalletSheet() { + setSheet(SheetState.AddWallet) + } + + fun showWalletDetails(address: String) { + val target = state.value.wallets.firstOrNull { it.address == address } + if (target != null) { + setSheet(SheetState.WalletDetails(target)) + } + } + + fun dismissSheet() { + setSheet(SheetState.None) + } + + fun showUrlPrompt() { + _state.update { it.copy(isUrlPromptVisible = true) } + } + + fun hideUrlPrompt() { + _state.update { it.copy(isUrlPromptVisible = false) } + } + + fun importWallet(name: String, network: TonNetwork, words: List, version: String = DEFAULT_WALLET_VERSION) { + val cleaned = words.map { it.trim().lowercase() }.filter { it.isNotBlank() } + if (cleaned.size != 24) { + _state.update { it.copy(error = "Recovery phrase must contain 24 words") } + return + } + + val pending = PendingWalletRecord( + metadata = WalletMetadata(name.ifBlank { defaultWalletName(state.value.wallets.size) }, network, version), + mnemonic = cleaned, + ) + + viewModelScope.launch { + switchNetworkIfNeeded(network) + pendingWallets.addLast(pending) + val result = runCatching { + engine.addWalletFromMnemonic( + words = cleaned, + name = pending.metadata.name, + version = version, + network = network.asBridgeValue(), + ) + } + if (result.isSuccess) { + val newWalletAccount = result.getOrNull() + refreshWallets() + dismissSheet() + + // Automatically switch to the newly imported wallet + if (newWalletAccount != null) { + _state.update { it.copy(activeWalletAddress = newWalletAccount.address) } + saveActiveWalletPreference(newWalletAccount.address) + Log.d(LOG_TAG, "Auto-switched to newly imported wallet: ${newWalletAccount.address}") + } + + logEvent("Imported wallet '${pending.metadata.name}' (version: $version)") + } else { + pendingWallets.removeLastOrNull() + _state.update { it.copy(error = result.exceptionOrNull()?.message ?: "Failed to import wallet") } + } + } + } + + fun generateWallet(name: String, network: TonNetwork, version: String = DEFAULT_WALLET_VERSION) { + val words = generateMnemonic() + val pending = PendingWalletRecord( + metadata = WalletMetadata(name.ifBlank { defaultWalletName(state.value.wallets.size) }, network, version), + mnemonic = words, + ) + viewModelScope.launch { + switchNetworkIfNeeded(network) + pendingWallets.addLast(pending) + val result = runCatching { + engine.addWalletFromMnemonic( + words = words, + name = pending.metadata.name, + version = version, + network = network.asBridgeValue(), + ) + } + if (result.isSuccess) { + val newWalletAccount = result.getOrNull() + refreshWallets() + dismissSheet() + + // Automatically switch to the newly generated wallet + if (newWalletAccount != null) { + _state.update { it.copy(activeWalletAddress = newWalletAccount.address) } + saveActiveWalletPreference(newWalletAccount.address) + Log.d(LOG_TAG, "Auto-switched to newly generated wallet: ${newWalletAccount.address}") + } + + logEvent("Generated wallet '${pending.metadata.name}' (version: $version)") + } else { + pendingWallets.removeLastOrNull() + _state.update { it.copy(error = result.exceptionOrNull()?.message ?: "Failed to generate wallet") } + } + } + } + + fun handleTonConnectUrl(url: String) { + viewModelScope.launch { + val trimmed = url.trim() + val result = runCatching { engine.handleTonConnectUrl(trimmed) } + result.onSuccess { + hideUrlPrompt() + logEvent("Handled TON Connect URL") + }.onFailure { error -> + val message = error.message ?: "Ton Connect error" + val handled = if (message.contains("wallet is required", ignoreCase = true)) { + val fallbackNetworks = listOf(TonNetwork.MAINNET, TonNetwork.TESTNET) + fallbackNetworks.any { candidate -> + if (candidate == currentNetwork) return@any false + val retry = runCatching { + switchNetworkIfNeeded(candidate) + engine.handleTonConnectUrl(trimmed) + } + if (retry.isSuccess) { + hideUrlPrompt() + logEvent("Handled TON Connect URL") + true + } else { + false + } + } + } else { + false + } + + if (!handled) { + _state.update { it.copy(error = message) } + } + } + } + } + + fun approveConnect(request: ConnectRequestUi, wallet: WalletSummary) { + viewModelScope.launch { + val result = runCatching { + request.connectRequest?.approve(wallet.address) + ?: error("Request object not available") + } + + result.onSuccess { + dismissSheet() + refreshSessions() + logEvent("Approved connect for ${request.dAppName}") + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to approve connect request") } + } + } + } + + fun rejectConnect(request: ConnectRequestUi, reason: String = "User rejected") { + viewModelScope.launch { + val result = runCatching { + request.connectRequest?.reject(reason) + ?: error("Request object not available") + } + + result.onSuccess { + dismissSheet() + logEvent("Rejected connect for ${request.dAppName}") + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to reject connect request") } + } + } + } + + fun approveTransaction(request: TransactionRequestUi) { + viewModelScope.launch { + val result = runCatching { + request.iosStyleRequest?.approve() + ?: error("Request object not available") + } + + result.onSuccess { + dismissSheet() + refreshWallets() // Refresh to show updated balance after transaction is sent + refreshSessions() + logEvent("Approved and sent transaction ${request.id}") + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to approve transaction") } + } + } + } + + fun rejectTransaction(request: TransactionRequestUi, reason: String = "User rejected") { + viewModelScope.launch { + val result = runCatching { + request.iosStyleRequest?.reject(reason) + ?: error("Request object not available") + } + + result.onSuccess { + dismissSheet() + logEvent("Rejected transaction ${request.id}") + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to reject transaction") } + } + } + } + + fun approveSignData(request: SignDataRequestUi) { + viewModelScope.launch { + Log.d(LOG_TAG, "Approving sign data request ID: ${request.id}") + + val result = runCatching { + request.iosStyleRequest?.approve() + ?: error("Request object not available") + } + + result.onSuccess { signResult -> + dismissSheet() + + val signature = signResult.signature + + // Log full details to Android logcat + Log.d(LOG_TAG, "========================================") + Log.d(LOG_TAG, "✅ SIGN DATA APPROVED") + Log.d(LOG_TAG, "========================================") + Log.d(LOG_TAG, "Request ID: ${request.id}") + Log.d(LOG_TAG, "Payload Type: ${request.payloadType}") + Log.d(LOG_TAG, "Signature: $signature") + Log.d(LOG_TAG, "========================================") + + logEvent("✅ Sign data approved - Signature: ${signature.take(20)}...") + + _state.update { + it.copy( + status = "✅ Signed! Signature in logs & clipboard", + error = null, + clipboardContent = signature, + ) + } + + // Auto-hide success message after 10 seconds + launch { + delay(10000) + _state.update { currentState -> + // Only clear if the status is still the success message + if (currentState.status == "✅ Signed! Signature in logs & clipboard") { + currentState.copy(status = "WalletKit ready") + } else { + currentState + } + } + } + }.onFailure { error -> + Log.e(LOG_TAG, "❌ Sign data approval failed", error) + logEvent("❌ Sign data approval failed: ${error.message}") + _state.update { it.copy(error = error.message ?: "Failed to approve sign request") } + } + } + } + + fun rejectSignData(request: SignDataRequestUi, reason: String = "User rejected") { + viewModelScope.launch { + val result = runCatching { + request.iosStyleRequest?.reject(reason) + ?: error("Request object not available") + } + + result.onSuccess { + dismissSheet() + Log.d(LOG_TAG, "❌ Rejected sign request ${request.id}: $reason") + logEvent("❌ Rejected sign request") + _state.update { it.copy(status = "Sign data request rejected") } + + // Auto-hide rejection message after 10 seconds + launch { + delay(10000) + _state.update { currentState -> + if (currentState.status == "Sign data request rejected") { + currentState.copy(status = "WalletKit ready") + } else { + currentState + } + } + } + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to reject sign request") } + } + } + } + + // ============================================================ + // Sign Data Demo/Test Methods + // ============================================================ + + /** + * Triggers a test sign data request with text payload + */ + fun testSignDataText(walletAddress: String) { + viewModelScope.launch { + try { + val requestId = java.util.UUID.randomUUID().toString() + val message = "Welcome to TON!\n\nPlease sign this message to authenticate your wallet.\n\nThis is a demo of the Sign Data feature." + + // Create SignDataPayload as a JSON object then stringify it + val signDataPayload = JSONObject().apply { + put("schema_crc", 0) // 0 means text/comment payload + put("payload", message) + } + + // Create the signData RPC request following TON Connect protocol + val testRequest = JSONObject().apply { + put("id", requestId) + put("method", "signData") + put("from", "demo-dapp") + put("walletAddress", walletAddress) + put("domain", "demo.ton.org") + // params[0] should be a JSON STRING, not an object + put( + "params", + JSONArray().apply { + put(signDataPayload.toString()) + }, + ) + put( + "dAppInfo", + JSONObject().apply { + put("name", "WalletKit Demo") + put("url", "https://demo.ton.org") + }, + ) + } + + Log.d(LOG_TAG, "Creating test sign data request: $testRequest") + + // Inject through bridge - this will store it and emit the event + engine.injectSignDataRequest(testRequest) + logEvent("Test sign data request injected") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to create test sign data text request", e) + _state.update { it.copy(error = "Failed to create sign data request: ${e.message}") } + } + } + } + + /** + * Triggers a test sign data request with binary payload + */ + fun testSignDataBinary(walletAddress: String) { + viewModelScope.launch { + try { + val requestId = java.util.UUID.randomUUID().toString() + // Create some binary data (base64 encoded) + val binaryData = android.util.Base64.encodeToString( + "Hello, TON! This is binary data with special chars: 🚀💎".toByteArray(Charsets.UTF_8), + android.util.Base64.NO_WRAP, + ) + + // Create SignDataPayload for binary data + val signDataPayload = JSONObject().apply { + put("schema_crc", 1) // binary type + put("payload", binaryData) + } + + val testRequest = JSONObject().apply { + put("id", requestId) + put("method", "signData") + put("from", "demo-dapp") + put("walletAddress", walletAddress) + put("domain", "demo.ton.org") + // params[0] should be a JSON STRING + put( + "params", + JSONArray().apply { + put(signDataPayload.toString()) + }, + ) + put( + "dAppInfo", + JSONObject().apply { + put("name", "WalletKit Demo") + put("url", "https://demo.ton.org") + put("iconUrl", "https://ton.org/icon.png") + put("description", "Testing binary sign data") + }, + ) + } + + Log.d(LOG_TAG, "Created test binary sign data request: $requestId") + + // Inject through bridge - this will store it and emit the event + engine.injectSignDataRequest(testRequest) + logEvent("Injected test request into bridge") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to create test sign data binary request", e) + _state.update { it.copy(error = "Failed to create sign data request: ${e.message}") } + } + } + } + + /** + * Triggers a test sign data request with cell payload + */ + fun testSignDataCell(walletAddress: String) { + viewModelScope.launch { + try { + val requestId = java.util.UUID.randomUUID().toString() + + // Create SignDataPayload for cell data + // This is a simple TON cell containing "Hello, TON!" + val signDataPayload = JSONObject().apply { + put("schema_crc", 2) // cell type + put("schema", "message#_ text:string = Message;") + put("payload", "te6cckEBAQEAEQAAHgAAAABIZWxsbywgVE9OIb7WCx4=") // BOC encoded cell with "Hello, TON!" + } + + val testRequest = JSONObject().apply { + put("id", requestId) + put("method", "signData") + put("from", "demo-dapp") + put("walletAddress", walletAddress) + put("domain", "demo.ton.org") + // params[0] should be a JSON STRING + put( + "params", + JSONArray().apply { + put(signDataPayload.toString()) + }, + ) + put( + "dAppInfo", + JSONObject().apply { + put("name", "WalletKit Demo") + put("url", "https://demo.ton.org") + put("iconUrl", "https://ton.org/icon.png") + put("description", "Testing cell sign data") + }, + ) + } + + Log.d(LOG_TAG, "Created test cell sign data request: $requestId") + + // Inject through bridge - this will store it and emit the event + engine.injectSignDataRequest(testRequest) + logEvent("Injected test request into bridge") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to create test sign data cell request", e) + _state.update { it.copy(error = "Failed to create sign data request: ${e.message}") } + } + } + } + + fun testSignDataWithSession(walletAddress: String, sessionId: String) { + viewModelScope.launch { + try { + val requestId = java.util.UUID.randomUUID().toString() + + // Get session info for display + val session = _state.value.sessions.find { it.sessionId == sessionId } + val sessionName = session?.dAppName ?: "Unknown dApp" + val domain = session?.dAppUrl?.let { url -> + // Extract domain from URL + url.removePrefix("https://").removePrefix("http://").split("/").firstOrNull() ?: "unknown.domain" + } ?: "unknown.domain" + + // Create SignDataPayload for text (can be modified for other types) + val message = "Sign this message via connected dApp session:\n$sessionName" + val signDataPayload = JSONObject().apply { + put("schema_crc", 0) // text type + put("payload", message) + } + + val testRequest = JSONObject().apply { + put("id", requestId) + put("method", "signData") + put("from", sessionId) // REAL SESSION ID - will go through bridge! + put("walletAddress", walletAddress) + put("domain", domain) + // params[0] should be a JSON STRING + put( + "params", + JSONArray().apply { + put(signDataPayload.toString()) + }, + ) + put( + "dAppInfo", + JSONObject().apply { + put("name", sessionName) + put("url", session?.dAppUrl ?: "https://$domain") + put("iconUrl", session?.iconUrl ?: "") + put("description", "Testing text sign data with connected session") + }, + ) + // NOTE: No isLocal flag - this will use the bridge! + } + + Log.d(LOG_TAG, "Created text sign data request with session: $sessionId ($sessionName)") + + // Inject through bridge - signature will be sent back to dApp via bridge + engine.injectSignDataRequest(testRequest) + logEvent("Injected connected dApp text sign request: $sessionName") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to create sign data request with session", e) + _state.update { it.copy(error = "Failed to create sign data request: ${e.message}") } + } + } + } + + fun testSignDataBinaryWithSession(walletAddress: String, sessionId: String) { + viewModelScope.launch { + try { + val requestId = java.util.UUID.randomUUID().toString() + + // Get session info for display + val session = _state.value.sessions.find { it.sessionId == sessionId } + val sessionName = session?.dAppName ?: "Unknown dApp" + val domain = session?.dAppUrl?.let { url -> + // Extract domain from URL + url.removePrefix("https://").removePrefix("http://").split("/").firstOrNull() ?: "unknown.domain" + } ?: "unknown.domain" + + // Create SignDataPayload for binary data + // Example: "Binary data from wallet" as base64 + val binaryData = "QmluYXJ5IGRhdGEgZnJvbSB3YWxsZXQ=" // base64 encoded + val signDataPayload = JSONObject().apply { + put("schema_crc", 1) // binary type + put("payload", binaryData) + } + + val testRequest = JSONObject().apply { + put("id", requestId) + put("method", "signData") + put("from", sessionId) // REAL SESSION ID - will go through bridge! + put("walletAddress", walletAddress) + put("domain", domain) + put( + "params", + JSONArray().apply { + put(signDataPayload.toString()) + }, + ) + put( + "dAppInfo", + JSONObject().apply { + put("name", sessionName) + put("url", session?.dAppUrl ?: "https://$domain") + put("iconUrl", session?.iconUrl ?: "") + put("description", "Testing binary sign data with connected session") + }, + ) + } + + Log.d(LOG_TAG, "Created binary sign data request with session: $sessionId ($sessionName)") + + engine.injectSignDataRequest(testRequest) + logEvent("Injected connected dApp binary sign request: $sessionName") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to create binary sign data request with session", e) + _state.update { it.copy(error = "Failed to create binary sign data request: ${e.message}") } + } + } + } + + fun testSignDataCellWithSession(walletAddress: String, sessionId: String) { + viewModelScope.launch { + try { + val requestId = java.util.UUID.randomUUID().toString() + + // Get session info for display + val session = _state.value.sessions.find { it.sessionId == sessionId } + val sessionName = session?.dAppName ?: "Unknown dApp" + val domain = session?.dAppUrl?.let { url -> + // Extract domain from URL + url.removePrefix("https://").removePrefix("http://").split("/").firstOrNull() ?: "unknown.domain" + } ?: "unknown.domain" + + // Create SignDataPayload for cell data + val signDataPayload = JSONObject().apply { + put("schema_crc", 2) // cell type + put("schema", "message#_ text:string = Message;") + put("payload", "te6cckEBAQEAEQAAHgAAAABIZWxsbywgVE9OIb7WCx4=") // BOC encoded cell with "Hello, TON!" + } + + val testRequest = JSONObject().apply { + put("id", requestId) + put("method", "signData") + put("from", sessionId) // REAL SESSION ID - will go through bridge! + put("walletAddress", walletAddress) + put("domain", domain) + put( + "params", + JSONArray().apply { + put(signDataPayload.toString()) + }, + ) + put( + "dAppInfo", + JSONObject().apply { + put("name", sessionName) + put("url", session?.dAppUrl ?: "https://$domain") + put("iconUrl", session?.iconUrl ?: "") + put("description", "Testing cell sign data with connected session") + }, + ) + } + + Log.d(LOG_TAG, "Created cell sign data request with session: $sessionId ($sessionName)") + + engine.injectSignDataRequest(testRequest) + logEvent("Injected connected dApp cell sign request: $sessionName") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to create cell sign data request with session", e) + _state.update { it.copy(error = "Failed to create sign data request: ${e.message}") } + } + } + } + + fun disconnectSession(sessionId: String) { + viewModelScope.launch { + val result = runCatching { engine.disconnectSession(sessionId) } + result.onSuccess { + refreshSessions() + logEvent("Disconnected session $sessionId") + // Session data is managed internally by the bridge + }.onFailure { error -> + _state.update { it.copy(error = error.message ?: "Failed to disconnect session") } + } + } + } + + fun openSendTransactionSheet(walletAddress: String) { + val wallet = state.value.wallets.firstOrNull { it.address == walletAddress } + if (wallet != null) { + setSheet(SheetState.SendTransaction(wallet)) + } + } + + fun sendTransaction(walletAddress: String, recipient: String, amount: String, comment: String = "") { + viewModelScope.launch { + _state.update { it.copy(isSendingTransaction = true, error = null) } + val result = runCatching { + val normalizedRecipient = recipient.trim() + if (normalizedRecipient.isEmpty()) { + throw IllegalArgumentException("Recipient address is required") + } + + val amountDecimal = + amount.trim() + .takeIf { it.isNotEmpty() } + ?.toBigDecimalOrNull() + ?: throw IllegalArgumentException("Invalid amount") + + if (amountDecimal <= BigDecimal.ZERO) { + throw IllegalArgumentException("Amount must be greater than zero") + } + + val nanoTonAmount = + amountDecimal + .movePointRight(9) + .setScale(0, RoundingMode.HALF_UP) + .toPlainString() + + val normalizedComment = comment.trim() + + // Note: This triggers handleNewTransaction which fires a transactionRequest event + // The event will be caught in onBridgeEvent() and shown in a TransactionRequestSheet + // where the user can see fees and approve/reject + val response = if (normalizedComment.isNotEmpty()) { + logEvent("Creating transaction with comment: ${normalizedComment.take(20)}${if (normalizedComment.length > 20) "..." else ""}") + engine.sendTransaction(walletAddress, normalizedRecipient, nanoTonAmount, normalizedComment) + } else { + engine.sendTransaction(walletAddress, normalizedRecipient, nanoTonAmount) + } + logEvent("Created transaction request for $amount TON to ${normalizedRecipient.take(8)}…") + response + } + + result.onSuccess { + // Don't dismiss sheet yet - wait for transactionRequest event + // The sheet will be replaced by TransactionRequestSheet + _state.update { it.copy(isSendingTransaction = false) } + }.onFailure { error -> + _state.update { + it.copy( + isSendingTransaction = false, + error = error.message ?: "Failed to create transaction", + ) + } + } + } + } + + fun toggleWalletSwitcher() { + _state.update { it.copy(isWalletSwitcherExpanded = !it.isWalletSwitcherExpanded) } + } + + fun switchWallet(address: String) { + viewModelScope.launch { + val wallet = state.value.wallets.firstOrNull { it.address == address } + if (wallet == null) { + _state.update { it.copy(error = "Wallet not found") } + return@launch + } + + _state.update { + it.copy( + activeWalletAddress = address, + isWalletSwitcherExpanded = false, + error = null, + ) + } + + // Save active wallet preference + saveActiveWalletPreference(address) + + // Refresh wallet state to get latest balance and transactions + refreshWallets() + logEvent("Switched to wallet: ${wallet.name}") + refreshTransactions(address) + } + } + + /** + * Save the active wallet address to persistent storage. + */ + private fun saveActiveWalletPreference(address: String) { + viewModelScope.launch { + try { + val updatedPrefs = UserPreferences( + activeWalletAddress = address, + ) + storage.saveUserPreferences(updatedPrefs) + Log.d(LOG_TAG, "Saved active wallet preference: $address") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to save active wallet preference", e) + } + } + } + + fun refreshTransactions(address: String? = state.value.activeWalletAddress, limit: Int = TRANSACTION_FETCH_LIMIT) { + val targetAddress = address ?: return + viewModelScope.launch { + val refreshErrorMessage = "Failed to refresh transactions" + _state.update { it.copy(isLoadingTransactions = true) } + + // Try to get cached transactions first for immediate display + val cachedTransactions = transactionCache.get(targetAddress) + if (cachedTransactions != null) { + Log.d(LOG_TAG, "Using cached transactions for $targetAddress: ${cachedTransactions.size} items") + _state.update { current -> + val updatedWallets = current.wallets.map { summary -> + if (summary.address == targetAddress) { + summary.copy(transactions = cachedTransactions) + } else { + summary + } + } + current.copy(wallets = updatedWallets) + } + } + + // Fetch fresh transactions from network + val result = runCatching { engine.getRecentTransactions(targetAddress, limit) } + result.onSuccess { newTransactions -> + // Update cache with new transactions (merges with existing) + val mergedTransactions = transactionCache.update(targetAddress, newTransactions) + + // Calculate diff for logging/debugging + val oldList = cachedTransactions ?: emptyList() + if (oldList.isNotEmpty()) { + val newItems = TransactionDiffUtil.getNewTransactions(oldList, mergedTransactions) + if (newItems.isNotEmpty()) { + Log.d(LOG_TAG, "Found ${newItems.size} new transactions for $targetAddress") + } + } + + _state.update { current -> + val updatedWallets = current.wallets.map { summary -> + if (summary.address == targetAddress) { + summary.copy(transactions = mergedTransactions) + } else { + summary + } + } + val updatedError = if (current.error == refreshErrorMessage) null else current.error + current.copy( + wallets = updatedWallets, + isLoadingTransactions = false, + error = updatedError, + ) + } + }.onFailure { error -> + Log.e(LOG_TAG, "Failed to refresh transactions for $targetAddress", error) + _state.update { + it.copy( + isLoadingTransactions = false, + error = error.message ?: refreshErrorMessage, + ) + } + } + } + } + + fun showTransactionDetail(transactionHash: String, walletAddress: String) { + val wallet = state.value.wallets.firstOrNull { it.address == walletAddress } ?: return + val transactions = wallet.transactions ?: return + + // Find the transaction by hash + val tx = transactions.firstOrNull { it.hash == transactionHash } ?: return + + // Parse transaction details + val detail = parseTransactionDetail(tx, walletAddress) + _state.update { it.copy(sheetState = SheetState.TransactionDetail(detail)) } + } + + private fun parseTransactionDetail(tx: Transaction, walletAddress: String): TransactionDetailUi { + val isOutgoing = tx.type == TransactionType.OUTGOING + + // Transaction already has parsed data from the bridge + return TransactionDetailUi( + hash = tx.hash, + timestamp = tx.timestamp, + amount = formatNanoTon(tx.amount), + fee = tx.fee?.let { formatNanoTon(it) } ?: "0 TON", + fromAddress = tx.sender ?: (if (isOutgoing) walletAddress else "Unknown"), + toAddress = tx.recipient ?: (if (!isOutgoing) walletAddress else "Unknown"), + comment = tx.comment, + status = "Success", // Transactions from bridge are already filtered/successful + lt = tx.lt ?: "0", + blockSeqno = tx.blockSeqno ?: 0, + isOutgoing = isOutgoing, + ) + } + + private fun formatNanoTon(nanoTon: String): String = try { + val value = nanoTon.toLongOrNull() ?: 0L + val ton = value.toDouble() / 1_000_000_000.0 + String.format(Locale.US, "%.4f", ton) + } catch (e: Exception) { + "0.0000" + } + + fun removeWallet(address: String) { + viewModelScope.launch { + val wallet = state.value.wallets.firstOrNull { it.address == address } + if (wallet == null) { + _state.update { it.copy(error = "Wallet not found") } + return@launch + } + + val removeResult = runCatching { engine.removeWallet(address) } + if (removeResult.isFailure) { + val reason = removeResult.exceptionOrNull()?.message ?: "Failed to remove wallet" + _state.update { it.copy(error = reason) } + return@launch + } + + runCatching { storage.clear(address) }.onFailure { + Log.w(LOG_TAG, "removeWallet: failed to clear storage for $address", it) + } + + // Clear transaction cache for removed wallet + transactionCache.clear(address) + + // Note: Sessions are managed internally by the bridge. + // When a wallet is removed from the bridge, associated sessions are automatically cleaned up. + + walletMetadata.remove(address) + + _state.update { + val filteredWallets = it.wallets.filterNot { summary -> summary.address == address } + val newActiveAddress = when { + filteredWallets.isEmpty() -> null + it.activeWalletAddress == address -> filteredWallets.first().address + else -> it.activeWalletAddress + } + it.copy( + wallets = filteredWallets, + activeWalletAddress = newActiveAddress, + isWalletSwitcherExpanded = if (filteredWallets.size <= 1) false else it.isWalletSwitcherExpanded, + ) + } + + refreshWallets() + refreshSessions() // Refresh to update UI with removed sessions + + logEvent("Removed wallet: ${wallet.name}") + } + } + + fun renameWallet(address: String, newName: String) { + val metadata = walletMetadata[address] + if (metadata == null) { + _state.update { it.copy(error = "Wallet not found") } + return + } + + val updated = metadata.copy(name = newName.ifBlank { defaultWalletName(0) }) + walletMetadata[address] = updated + + // Update storage + viewModelScope.launch { + val storedWallet = storage.loadWallet(address) + if (storedWallet != null) { + val updatedRecord = WalletRecord( + mnemonic = storedWallet.mnemonic, + name = updated.name, + network = updated.network.asBridgeValue(), + version = updated.version, + ) + storage.saveWallet(address, updatedRecord) + } + + // Refresh to update UI + refreshWallets() + logEvent("Renamed wallet to: $newName") + } + } + + private suspend fun loadWalletSummaries(): List { + val accounts = engine.getWallets() + Log.d(LOG_TAG, "loadWalletSummaries: got ${accounts.size} accounts") + val knownAddresses = accounts.map { it.address }.toSet() + walletMetadata.keys.retainAll(knownAddresses) + + val result = mutableListOf() + for (account in accounts) { + val metadata = ensureMetadata(account) + + Log.d(LOG_TAG, "loadWalletSummaries: fetching state for ${account.address}") + val state = runCatching { + engine.getWalletState(account.address) + }.onFailure { + Log.e(LOG_TAG, "loadWalletSummaries: getWalletState failed for ${account.address}", it) + }.getOrNull() + val balance = state?.balance + Log.d(LOG_TAG, "loadWalletSummaries: balance for ${account.address} = $balance") + val formatted = balance?.let(::formatTon) + + // Try to get transactions from cache first + val cachedTransactions = transactionCache.get(account.address) + + // Fetch fresh transactions from network + val transactions = runCatching { + engine.getRecentTransactions(account.address, TRANSACTION_FETCH_LIMIT) + }.onFailure { + Log.w(LOG_TAG, "loadWalletSummaries: getRecentTransactions failed for ${account.address}", it) + }.getOrNull() + + // Update cache and use merged result + val finalTransactions = if (transactions != null) { + transactionCache.update(account.address, transactions) + } else { + // If fetch failed, use cached transactions or state transactions as fallback + cachedTransactions ?: state?.transactions + } + + // Get sessions connected to this wallet + val walletSessions = _state.value.sessions.filter { session -> + session.walletAddress == account.address + } + + val summary = WalletSummary( + address = account.address, + name = metadata.name, + network = metadata.network, + version = metadata.version.ifBlank { account.version }, + publicKey = account.publicKey, + balanceNano = balance, + balance = formatted, + transactions = finalTransactions, + lastUpdated = System.currentTimeMillis(), + connectedSessions = walletSessions, + ) + result.add(summary) + } + return result + } + + private suspend fun ensureMetadata(account: WalletAccount): WalletMetadata { + walletMetadata[account.address]?.let { return it } + + val pending = pendingWallets.removeLastOrNull() + val storedRecord = storage.loadWallet(account.address) + val metadata = pending?.metadata + ?: storedRecord?.let { + WalletMetadata( + name = it.name, + network = TonNetwork.fromBridge(it.network, currentNetwork), + version = it.version, + ) + } + ?: WalletMetadata( + name = defaultWalletName(account.index), + network = TonNetwork.fromBridge(account.network, currentNetwork), + version = account.version.ifBlank { DEFAULT_WALLET_VERSION }, + ) + walletMetadata[account.address] = metadata + + if (pending?.mnemonic != null) { + val record = WalletRecord( + mnemonic = pending.mnemonic, + name = metadata.name, + network = metadata.network.asBridgeValue(), + version = metadata.version, + ) + runCatching { storage.saveWallet(account.address, record) } + } else if (storedRecord != null) { + val needsUpdate = storedRecord.name != metadata.name || + storedRecord.network != metadata.network.asBridgeValue() || + storedRecord.version != metadata.version + if (needsUpdate) { + val record = WalletRecord( + mnemonic = storedRecord.mnemonic, + name = metadata.name, + network = metadata.network.asBridgeValue(), + version = metadata.version, + ) + runCatching { storage.saveWallet(account.address, record) } + } + } + + return metadata + } + + private suspend fun ensureWallet() { + val existing = runCatching { engine.getWallets() }.getOrDefault(emptyList()) + if (existing.isNotEmpty()) { + existing.forEach { ensureMetadata(it) } + return + } + + val metadata = WalletMetadata( + name = DEMO_WALLET_NAME, + network = currentNetwork, + version = DEFAULT_WALLET_VERSION, + ) + val pendingRecord = PendingWalletRecord(metadata = metadata, mnemonic = DEMO_MNEMONIC) + pendingWallets.addLast(pendingRecord) + runCatching { + engine.addWalletFromMnemonic( + words = DEMO_MNEMONIC, + name = DEMO_WALLET_NAME, + version = DEFAULT_WALLET_VERSION, + network = currentNetwork.asBridgeValue(), + ) + }.onFailure { error -> + pendingWallets.remove(pendingRecord) + _state.update { it.copy(error = error.message ?: "Failed to prepare demo wallet") } + } + } + + private suspend fun reinitializeForNetwork( + target: TonNetwork, + ) { + val endpoints = networkEndpoints(target) + engine.init( + WalletKitBridgeConfig( + network = target.asBridgeValue(), + tonClientEndpoint = endpoints.tonClientEndpoint, + tonApiUrl = endpoints.tonApiUrl, + bridgeUrl = endpoints.bridgeUrl, + bridgeName = endpoints.bridgeName, + appName = "tonkeeper", // Use registered wallet name for TonConnect compatibility + // Storage is always persistent - managed internally by bridge + ), + ) + + currentNetwork = target + walletMetadata.clear() + pendingWallets.clear() + + // Wallets are automatically restored by bridge - no manual restoration needed + // Just ensure we have at least one wallet for demo purposes + ensureWallet() + } + + private suspend fun switchNetworkIfNeeded(target: TonNetwork) { + if (target == currentNetwork) return + reinitializeForNetwork(target) + refreshAll() + } + + private fun setSheet(sheet: SheetState) { + _state.update { it.copy(sheetState = sheet) } + } + + /** + * Event handler using sealed class pattern. + * This provides type-safe, exhaustive when() expressions. + */ + private fun onBridgeEvent(event: WalletKitEvent) { + when (event) { + is WalletKitEvent.ConnectRequestEvent -> { + // Request object contains all data plus approve/reject methods + val request = event.request + val dAppInfo = request.dAppInfo + + // Convert to UI model for existing sheets + val uiRequest = ConnectRequestUi( + id = request.requestId.toString(), + dAppName = dAppInfo?.name ?: "Unknown dApp", + dAppUrl = dAppInfo?.url ?: "", + manifestUrl = dAppInfo?.manifestUrl ?: "", + iconUrl = dAppInfo?.iconUrl, + permissions = request.permissions.map { permission -> + ConnectPermissionUi( + name = permission.name ?: "unknown", + title = permission.title ?: permission.name?.replaceFirstChar { it.uppercase() } ?: "Unknown", + description = permission.description ?: "Allow access to ${permission.name}", + ) + }, + requestedItems = request.permissions.mapNotNull { it.name }, + raw = org.json.JSONObject(), // Not needed with this API + connectRequest = request, // Store for direct approve/reject + ) + + setSheet(SheetState.Connect(uiRequest)) + logEvent("Connect request from ${dAppInfo?.name ?: "Unknown dApp"}") + } + + is WalletKitEvent.TransactionRequestEvent -> { + // Request object contains all data plus approve/reject methods + val request = event.request + val dAppInfo = request.dAppInfo + val txRequest = request.request + + // Extract wallet address from the raw event data if available + // Otherwise use the active wallet address + val walletAddress = state.value.activeWalletAddress ?: "" + + // Convert to UI model + val messages = listOf( + TransactionMessageUi( + to = txRequest.recipient, + amount = txRequest.amount, + comment = txRequest.comment, + payload = txRequest.payload, + stateInit = null, + ), + ) + + val uiRequest = TransactionRequestUi( + id = request.requestId.toString(), + walletAddress = walletAddress, + dAppName = dAppInfo?.name ?: "Unknown dApp", + validUntil = null, + messages = messages, + preview = request.preview, // Pass preview data from bridge + raw = org.json.JSONObject(), + iosStyleRequest = request, // Store for direct approve/reject + ) + + setSheet(SheetState.Transaction(uiRequest)) + logEvent("Transaction request ${request.requestId}") + } + + is WalletKitEvent.SignDataRequestEvent -> { + // Request object contains all data plus approve/reject methods + val request = event.request + val dAppInfo = request.dAppInfo + + // Use typed event data instead of legacy parsed data + val typedEvent = request.event + val eventPayload = typedEvent.request + val eventPreview = typedEvent.preview + + // Extract payload content based on type + val payloadType = eventPayload?.type?.name?.lowercase() ?: "unknown" + val payloadContent = when (eventPayload?.type) { + io.ton.walletkit.presentation.event.SignDataType.TEXT -> { + eventPayload.text ?: "" + } + io.ton.walletkit.presentation.event.SignDataType.BINARY -> { + eventPayload.bytes ?: "" + } + io.ton.walletkit.presentation.event.SignDataType.CELL -> { + eventPayload.cell ?: "" + } + else -> "" + } + + // Generate preview based on type and use event preview if available + val preview = eventPreview?.content ?: when (eventPayload?.type) { + io.ton.walletkit.presentation.event.SignDataType.TEXT -> { + // For text payloads, show the text directly (decode if base64) + val text = eventPayload.text ?: "" + text.take(200).let { + if (text.length > 200) "$it..." else it + } + } + io.ton.walletkit.presentation.event.SignDataType.BINARY -> { + // For binary payloads, show base64 preview + val bytes = eventPayload.bytes ?: "" + "Binary data (${bytes.length} chars)\n${bytes.take(100)}..." + } + io.ton.walletkit.presentation.event.SignDataType.CELL -> { + // For cell payloads, show BOC preview + val cell = eventPayload.cell ?: "" + "Cell BOC (${cell.length} chars)\n${cell.take(100)}..." + } + else -> { + // Unknown type - show what we have + payloadContent.take(100).let { + if (payloadContent.length > 100) "$it..." else it + } + } + } + + val uiRequest = SignDataRequestUi( + id = request.requestId.toString(), + walletAddress = typedEvent.walletAddress ?: typedEvent.from ?: "", + payloadType = payloadType, + payloadContent = payloadContent, + preview = preview, + raw = org.json.JSONObject(), + iosStyleRequest = request, // Store for direct approve/reject + ) + + setSheet(SheetState.SignData(uiRequest)) + logEvent("Sign data request ${request.requestId}: type=$payloadType") + } + + is WalletKitEvent.DisconnectEvent -> { + Log.d(LOG_TAG, "Received disconnect event: sessionId=${event.sessionId}") + viewModelScope.launch { + // Session data is managed internally by the bridge + runCatching { engine.disconnectSession(event.sessionId) } + refreshSessions() + logEvent("Session disconnected") + } + } + + is WalletKitEvent.StateChangedEvent -> { + Log.d(LOG_TAG, "Wallet state changed: ${event.address}") + refreshWallets() + } + + is WalletKitEvent.SessionsChangedEvent -> { + Log.d(LOG_TAG, "Sessions changed") + refreshSessions() + } + } + } + + private fun logEvent(message: String) { + _state.update { + val events = listOf(message) + it.events + it.copy(events = events.take(MAX_EVENT_LOG)) + } + } + + private fun networkEndpoints(network: TonNetwork): NetworkEndpoints = when (network) { + TonNetwork.MAINNET -> NetworkEndpoints( + tonClientEndpoint = "https://toncenter.com/api/v2/jsonRPC", + tonApiUrl = "https://tonapi.io", + bridgeUrl = DEFAULT_BRIDGE_URL, + bridgeName = DEFAULT_BRIDGE_NAME, + ) + TonNetwork.TESTNET -> NetworkEndpoints( + tonClientEndpoint = "https://testnet.toncenter.com/api/v2/jsonRPC", + tonApiUrl = "https://testnet.tonapi.io", + bridgeUrl = DEFAULT_BRIDGE_URL, + bridgeName = DEFAULT_BRIDGE_NAME, + ) + } + + override fun onCleared() { + balanceJob?.cancel() + bridgeListener?.close() + viewModelScope.launch { + runCatching { engine.destroy() } + } + super.onCleared() + } + + private fun defaultWalletName(index: Int): String = "Wallet ${index + 1}" + + private fun parseTimestamp(value: String?): Long? { + if (value.isNullOrBlank()) return null + return runCatching { + when { + value.matches(NUMERIC_PATTERN) -> value.toLong() + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> Instant.parse(value).toEpochMilli() + else -> parseIso8601Timestamp(value) + } + }.getOrNull() + } + + private fun parseIso8601Timestamp(value: String): Long { + val candidates = buildList { + add(value) + normalizeIso8601Fraction(value)?.let { add(it) } + } + + candidates.forEach { candidate -> + ISO8601_PATTERNS.forEach { pattern -> + val parsed = runCatching { + SimpleDateFormat(pattern, Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(candidate)?.time + }.getOrNull() + + if (parsed != null) { + return parsed + } + } + } + + throw IllegalArgumentException("Unsupported timestamp format: $value") + } + + private fun normalizeIso8601Fraction(value: String): String? { + val timeSeparator = value.indexOf('T') + if (timeSeparator == -1) return null + + val fractionStart = value.indexOf('.', startIndex = timeSeparator) + if (fractionStart == -1) return null + + val zoneStart = value.indexOfAny(charArrayOf('Z', '+', '-'), startIndex = fractionStart) + if (zoneStart == -1) return null + + val fraction = value.substring(fractionStart + 1, zoneStart) + if (fraction.isEmpty()) return null + + val normalized = when { + fraction.length == 3 -> return null + fraction.length < 3 -> fraction.padEnd(3, '0') + else -> fraction.substring(0, 3) + } + + val prefix = value.substring(0, fractionStart) + val suffix = value.substring(zoneStart) + return "$prefix.$normalized$suffix" + } + + private fun formatTon(raw: String): String = runCatching { + BigDecimal(raw) + .movePointLeft(9) + .setScale(4, RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() + }.getOrElse { raw } + + private data class NetworkEndpoints( + val tonClientEndpoint: String, + val tonApiUrl: String, + val bridgeUrl: String, + val bridgeName: String, + ) + + private fun generateMnemonic(): List = List(24) { DEMO_WORDS.random() } + + private fun sanitizeUrl(value: String?): String? { + if (value.isNullOrBlank()) return null + if (value.equals("null", ignoreCase = true)) return null + return value + } + + companion object { + private val NUMERIC_PATTERN = Regex("^-?\\d+") + private val ISO8601_PATTERNS = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + ) + private const val BALANCE_REFRESH_MS = 20_000L + private const val MAX_EVENT_LOG = 12 + private const val DEFAULT_WALLET_VERSION = "v4r2" + private const val TRANSACTION_FETCH_LIMIT = 20 + private val DEFAULT_NETWORK = TonNetwork.MAINNET + private const val LOG_TAG = "WalletKitVM" + private const val DEFAULT_BRIDGE_URL = "https://bridge.tonapi.io/bridge" + private const val DEFAULT_BRIDGE_NAME = "tonkeeper" + + private val DEMO_WORDS = listOf( + "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", + "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", + "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", + "adapt", "addict", "address", "adjust", "admit", "adult", "advance", "advice", + ) + + private val DEMO_MNEMONIC = listOf( + "canvas", "puzzle", "ski", "divide", "crime", "arrow", + "object", "canvas", "point", "cover", "method", "bargain", + "siren", "bean", "shrimp", "found", "gravity", "vivid", + "pelican", "replace", "tuition", "screen", "orange", "album", + ) + + private const val DEMO_WALLET_NAME = "Demo Wallet" + + fun factory( + engine: WalletKitEngine, + storage: DemoAppStorage, + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(WalletKitViewModel::class.java)) + return WalletKitViewModel(engine, storage) as T + } + } + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/cache/TransactionCache.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/cache/TransactionCache.kt new file mode 100644 index 000000000..48e79bcdc --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/cache/TransactionCache.kt @@ -0,0 +1,158 @@ +package io.ton.walletkit.demo.cache + +import android.util.Log +import io.ton.walletkit.domain.model.Transaction +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Cache for wallet transactions with deduplication and merging capabilities. + * Uses transaction hash as unique identifier. + */ +class TransactionCache { + private val mutex = Mutex() + + // Map of wallet address -> list of transactions (ordered by timestamp desc) + private val cache = mutableMapOf>() + + // Track last update time for each wallet + private val lastUpdateTime = mutableMapOf() + + /** + * Get cached transactions for a wallet address. + * Returns null if no cache exists for this wallet. + */ + suspend fun get(walletAddress: String): List? = mutex.withLock { + cache[walletAddress] + } + + /** + * Update cache with new transactions, merging with existing ones. + * New transactions are deduplicated by hash and merged with existing cache. + * The list is sorted by timestamp (descending) and limited to maxSize. + * + * @param walletAddress The wallet address + * @param newTransactions The new transactions to add/update + * @param maxSize Maximum number of transactions to keep in cache (default 100) + * @return The merged and sorted transaction list + */ + suspend fun update( + walletAddress: String, + newTransactions: List, + maxSize: Int = 100, + ): List = mutex.withLock { + val existing = cache[walletAddress] ?: emptyList() + + // Create a map of hash -> transaction for deduplication + // New transactions take priority over existing ones (in case of updates) + val transactionMap = mutableMapOf() + + // Add existing transactions first + existing.forEach { tx -> + transactionMap[tx.hash] = tx + } + + // Add/update with new transactions (overwrites existing) + newTransactions.forEach { tx -> + transactionMap[tx.hash] = tx + } + + // Convert back to list, sort by timestamp (descending), and limit size + val merged = transactionMap.values + .sortedByDescending { it.timestamp } + .take(maxSize) + + // Update cache + cache[walletAddress] = merged + lastUpdateTime[walletAddress] = System.currentTimeMillis() + + Log.d( + TAG, + "TransactionCache updated for $walletAddress: " + + "${existing.size} existing + ${newTransactions.size} new = ${merged.size} total " + + "(${newTransactions.size - (merged.size - existing.size)} duplicates)", + ) + + merged + } + + /** + * Replace the entire cache for a wallet (useful for full refresh). + */ + suspend fun replace( + walletAddress: String, + transactions: List, + maxSize: Int = 100, + ): List = mutex.withLock { + val sorted = transactions + .distinctBy { it.hash } // Deduplicate + .sortedByDescending { it.timestamp } + .take(maxSize) + + cache[walletAddress] = sorted + lastUpdateTime[walletAddress] = System.currentTimeMillis() + + Log.d(TAG, "TransactionCache replaced for $walletAddress: ${sorted.size} transactions") + + sorted + } + + /** + * Clear cache for a specific wallet. + */ + suspend fun clear(walletAddress: String) = mutex.withLock { + cache.remove(walletAddress) + lastUpdateTime.remove(walletAddress) + Log.d(TAG, "TransactionCache cleared for $walletAddress") + } + + /** + * Clear all caches. + */ + suspend fun clearAll() = mutex.withLock { + cache.clear() + lastUpdateTime.clear() + Log.d(TAG, "TransactionCache cleared all") + } + + /** + * Get the last update time for a wallet (in milliseconds). + */ + suspend fun getLastUpdateTime(walletAddress: String): Long? = mutex.withLock { + lastUpdateTime[walletAddress] + } + + /** + * Check if cache is stale (older than maxAge milliseconds). + */ + suspend fun isStale(walletAddress: String, maxAge: Long = 60_000L): Boolean = mutex.withLock { + val lastUpdate = lastUpdateTime[walletAddress] ?: return@withLock true + System.currentTimeMillis() - lastUpdate > maxAge + } + + /** + * Get cache statistics for debugging. + */ + suspend fun getStats(): CacheStats = mutex.withLock { + CacheStats( + walletCount = cache.size, + totalTransactions = cache.values.sumOf { it.size }, + oldestUpdate = lastUpdateTime.values.minOrNull() ?: 0L, + newestUpdate = lastUpdateTime.values.maxOrNull() ?: 0L, + ) + } + + companion object { + private const val TAG = "TransactionCache" + } +} + +/** + * Statistics about the transaction cache. + */ +data class CacheStats( + val walletCount: Int, + val totalTransactions: Int, + val oldestUpdate: Long, + val newestUpdate: Long, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/ConnectPermissionUi.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/ConnectPermissionUi.kt new file mode 100644 index 000000000..4cf8a146c --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/ConnectPermissionUi.kt @@ -0,0 +1,7 @@ +package io.ton.walletkit.demo.model + +data class ConnectPermissionUi( + val name: String, + val title: String, + val description: String, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/ConnectRequestUi.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/ConnectRequestUi.kt new file mode 100644 index 000000000..b0067ae84 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/ConnectRequestUi.kt @@ -0,0 +1,16 @@ +package io.ton.walletkit.demo.model + +import io.ton.walletkit.presentation.request.ConnectRequest +import org.json.JSONObject + +data class ConnectRequestUi( + val id: String, + val dAppName: String, + val dAppUrl: String, + val manifestUrl: String, + val iconUrl: String?, + val permissions: List, + val requestedItems: List, + val raw: JSONObject, + val connectRequest: ConnectRequest? = null, // Request object with approve/reject helpers +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/PendingWalletRecord.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/PendingWalletRecord.kt new file mode 100644 index 000000000..940a6dcda --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/PendingWalletRecord.kt @@ -0,0 +1,6 @@ +package io.ton.walletkit.demo.model + +data class PendingWalletRecord( + val metadata: WalletMetadata, + val mnemonic: List?, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/SessionSummary.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/SessionSummary.kt new file mode 100644 index 000000000..c3ab2883c --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/SessionSummary.kt @@ -0,0 +1,12 @@ +package io.ton.walletkit.demo.model + +data class SessionSummary( + val sessionId: String, + val dAppName: String, + val walletAddress: String, + val dAppUrl: String?, + val manifestUrl: String?, + val iconUrl: String?, + val createdAt: Long?, + val lastActivity: Long?, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/SignDataRequestUi.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/SignDataRequestUi.kt new file mode 100644 index 000000000..5a4b04d02 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/SignDataRequestUi.kt @@ -0,0 +1,14 @@ +package io.ton.walletkit.demo.model + +import io.ton.walletkit.presentation.request.SignDataRequest +import org.json.JSONObject + +data class SignDataRequestUi( + val id: String, + val walletAddress: String, + val payloadType: String, + val payloadContent: String, + val preview: String?, + val raw: JSONObject, + val iosStyleRequest: SignDataRequest? = null, // Request object with approve/reject helpers +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TonNetwork.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TonNetwork.kt new file mode 100644 index 000000000..07004fba1 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TonNetwork.kt @@ -0,0 +1,23 @@ +package io.ton.walletkit.demo.model + +enum class TonNetwork { + MAINNET, + TESTNET, + ; + + fun asBridgeValue(): String = when (this) { + MAINNET -> "mainnet" + TESTNET -> "testnet" + } + + companion object { + fun fromBridge(value: String?, fallback: TonNetwork = MAINNET): TonNetwork { + val normalized = value?.trim()?.lowercase() + return when (normalized) { + "mainnet" -> MAINNET + "testnet" -> TESTNET + else -> fallback + } + } + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionDetailUi.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionDetailUi.kt new file mode 100644 index 000000000..2e9c5bb0e --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionDetailUi.kt @@ -0,0 +1,15 @@ +package io.ton.walletkit.demo.model + +data class TransactionDetailUi( + val hash: String, + val timestamp: Long, + val isOutgoing: Boolean, + val amount: String, + val fee: String, + val fromAddress: String?, + val toAddress: String?, + val comment: String?, + val status: String, + val blockSeqno: Int, + val lt: String, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionMessageUi.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionMessageUi.kt new file mode 100644 index 000000000..1729ccf99 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionMessageUi.kt @@ -0,0 +1,9 @@ +package io.ton.walletkit.demo.model + +data class TransactionMessageUi( + val to: String, + val amount: String, + val comment: String?, + val payload: String?, + val stateInit: String?, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionRequestUi.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionRequestUi.kt new file mode 100644 index 000000000..371e7e4bc --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/TransactionRequestUi.kt @@ -0,0 +1,15 @@ +package io.ton.walletkit.demo.model + +import io.ton.walletkit.presentation.request.TransactionRequest +import org.json.JSONObject + +data class TransactionRequestUi( + val id: String, + val walletAddress: String, + val dAppName: String, + val validUntil: Long?, + val messages: List, + val preview: String?, + val raw: JSONObject, + val iosStyleRequest: TransactionRequest? = null, // Request object with approve/reject helpers +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/WalletMetadata.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/WalletMetadata.kt new file mode 100644 index 000000000..74b2cd92c --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/WalletMetadata.kt @@ -0,0 +1,7 @@ +package io.ton.walletkit.demo.model + +data class WalletMetadata( + val name: String, + val network: TonNetwork, + val version: String, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/WalletSummary.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/WalletSummary.kt new file mode 100644 index 000000000..81ce854c9 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/model/WalletSummary.kt @@ -0,0 +1,16 @@ +package io.ton.walletkit.demo.model + +import io.ton.walletkit.domain.model.Transaction + +data class WalletSummary( + val address: String, + val name: String, + val network: TonNetwork, + val version: String, + val publicKey: String?, + val balanceNano: String?, + val balance: String?, + val transactions: List?, + val lastUpdated: Long?, + val connectedSessions: List = emptyList(), // Sessions connected to this wallet +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/state/SheetState.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/state/SheetState.kt new file mode 100644 index 000000000..776c7a025 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/state/SheetState.kt @@ -0,0 +1,18 @@ +package io.ton.walletkit.demo.state + +import io.ton.walletkit.demo.model.ConnectRequestUi +import io.ton.walletkit.demo.model.SignDataRequestUi +import io.ton.walletkit.demo.model.TransactionDetailUi +import io.ton.walletkit.demo.model.TransactionRequestUi +import io.ton.walletkit.demo.model.WalletSummary + +sealed interface SheetState { + data object None : SheetState + data object AddWallet : SheetState + data class Connect(val request: ConnectRequestUi) : SheetState + data class Transaction(val request: TransactionRequestUi) : SheetState + data class SignData(val request: SignDataRequestUi) : SheetState + data class WalletDetails(val wallet: WalletSummary) : SheetState + data class SendTransaction(val wallet: WalletSummary) : SheetState + data class TransactionDetail(val transaction: TransactionDetailUi) : SheetState +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/state/WalletUiState.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/state/WalletUiState.kt new file mode 100644 index 000000000..7d91f4622 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/state/WalletUiState.kt @@ -0,0 +1,23 @@ +package io.ton.walletkit.demo.state + +import io.ton.walletkit.demo.model.SessionSummary +import io.ton.walletkit.demo.model.WalletSummary + +data class WalletUiState( + val initialized: Boolean = false, + val status: String = "", + val wallets: List = emptyList(), + val activeWalletAddress: String? = null, + val sessions: List = emptyList(), + val sheetState: SheetState = SheetState.None, + val isUrlPromptVisible: Boolean = false, + val isWalletSwitcherExpanded: Boolean = false, + val isLoadingWallets: Boolean = false, + val isLoadingSessions: Boolean = false, + val isLoadingTransactions: Boolean = false, + val isSendingTransaction: Boolean = false, + val error: String? = null, + val events: List = emptyList(), + val lastUpdated: Long? = null, + val clipboardContent: String? = null, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/storage/DemoAppStorage.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/storage/DemoAppStorage.kt new file mode 100644 index 000000000..1ab213d94 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/storage/DemoAppStorage.kt @@ -0,0 +1,62 @@ +package io.ton.walletkit.demo.storage + +/** + * Storage interface for the WalletKit demo app. + * This is separate from the SDK's BridgeStorageAdapter and is used for + * demo app-specific data like wallet mnemonics, metadata, and user preferences. + * + * NOT part of the SDK - this is demo app internal storage. + */ +interface DemoAppStorage { + /** + * Save a wallet record (mnemonic + metadata). + */ + suspend fun saveWallet(address: String, record: WalletRecord) + + /** + * Load a wallet record by address. + */ + suspend fun loadWallet(address: String): WalletRecord? + + /** + * Load all stored wallet records. + */ + suspend fun loadAllWallets(): Map + + /** + * Clear wallet data for a specific address. + */ + suspend fun clear(address: String) + + /** + * Save user preferences (active wallet, etc). + */ + suspend fun saveUserPreferences(preferences: UserPreferences) + + /** + * Load user preferences. + */ + suspend fun loadUserPreferences(): UserPreferences? + + /** + * Clear all demo app data. + */ + suspend fun clearAll() +} + +/** + * Wallet record stored in demo app storage. + */ +data class WalletRecord( + val mnemonic: List, + val name: String, + val network: String, + val version: String, +) + +/** + * User preferences for the demo app. + */ +data class UserPreferences( + val activeWalletAddress: String? = null, +) diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/storage/SecureDemoAppStorage.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/storage/SecureDemoAppStorage.kt new file mode 100644 index 000000000..305e2835f --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/storage/SecureDemoAppStorage.kt @@ -0,0 +1,120 @@ +package io.ton.walletkit.demo.storage + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +/** + * Secure implementation of DemoAppStorage using Android Keystore + EncryptedSharedPreferences. + * Encrypts sensitive data like wallet mnemonics. + * + * This is demo app internal storage - NOT part of the SDK. + */ +class SecureDemoAppStorage(context: Context) : DemoAppStorage { + private val appContext = context.applicationContext + + private val masterKey = MasterKey.Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val walletPrefs: SharedPreferences = EncryptedSharedPreferences.create( + appContext, + "walletkit_demo_wallets", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + private val userPrefs: SharedPreferences = EncryptedSharedPreferences.create( + appContext, + "walletkit_demo_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + override suspend fun saveWallet(address: String, record: WalletRecord): Unit = withContext(Dispatchers.IO) { + val json = JSONObject().apply { + put("mnemonic", JSONArray(record.mnemonic)) + put("name", record.name) + put("network", record.network) + put("version", record.version) + } + walletPrefs.edit().putString(walletKey(address), json.toString()).apply() + Log.d(TAG, "Saved wallet: $address") + } + + override suspend fun loadWallet(address: String): WalletRecord? = withContext(Dispatchers.IO) { + val jsonString = walletPrefs.getString(walletKey(address), null) ?: return@withContext null + try { + val json = JSONObject(jsonString) + val mnemonicArray = json.getJSONArray("mnemonic") + val mnemonic = List(mnemonicArray.length()) { mnemonicArray.getString(it) } + WalletRecord( + mnemonic = mnemonic, + name = json.getString("name"), + network = json.getString("network"), + version = json.getString("version"), + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse wallet record for $address", e) + null + } + } + + override suspend fun loadAllWallets(): Map = withContext(Dispatchers.IO) { + val result = mutableMapOf() + walletPrefs.all.forEach { (key, _) -> + if (key.startsWith(WALLET_PREFIX)) { + val address = key.removePrefix(WALLET_PREFIX) + loadWallet(address)?.let { record -> + result[address] = record + } + } + } + result + } + + override suspend fun clear(address: String): Unit = withContext(Dispatchers.IO) { + walletPrefs.edit().remove(walletKey(address)).apply() + Log.d(TAG, "Cleared wallet: $address") + } + + override suspend fun saveUserPreferences(preferences: UserPreferences): Unit = withContext(Dispatchers.IO) { + userPrefs.edit().apply { + preferences.activeWalletAddress?.let { + putString(PREF_ACTIVE_WALLET, it) + } ?: remove(PREF_ACTIVE_WALLET) + }.apply() + Log.d(TAG, "Saved user preferences") + } + + override suspend fun loadUserPreferences(): UserPreferences? = withContext(Dispatchers.IO) { + val activeWallet = userPrefs.getString(PREF_ACTIVE_WALLET, null) + if (activeWallet != null) { + UserPreferences(activeWalletAddress = activeWallet) + } else { + null + } + } + + override suspend fun clearAll(): Unit = withContext(Dispatchers.IO) { + walletPrefs.edit().clear().apply() + userPrefs.edit().clear().apply() + Log.d(TAG, "Cleared all demo app storage") + } + + private fun walletKey(address: String) = "$WALLET_PREFIX$address" + + companion object { + private const val TAG = "SecureDemoAppStorage" + private const val WALLET_PREFIX = "wallet:" + private const val PREF_ACTIVE_WALLET = "active_wallet_address" + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/MainActivity.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/MainActivity.kt new file mode 100644 index 000000000..fd4edd914 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/MainActivity.kt @@ -0,0 +1,64 @@ +package io.ton.walletkit.demo.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import io.ton.walletkit.demo.WalletKitDemoApp +import io.ton.walletkit.demo.WalletKitViewModel +import io.ton.walletkit.demo.ui.screen.WalletScreen + +class MainActivity : ComponentActivity() { + private val viewModel: WalletKitViewModel by viewModels { + val app = application as WalletKitDemoApp + WalletKitViewModel.factory(app.obtainEngine(), app.storage) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.background) { + val state by viewModel.state.collectAsState() + WalletScreen( + state = state, + onAddWalletClick = viewModel::openAddWalletSheet, + onUrlPromptClick = viewModel::showUrlPrompt, + onRefresh = viewModel::refreshAll, + onDismissSheet = viewModel::dismissSheet, + onWalletDetails = viewModel::showWalletDetails, + onSendFromWallet = viewModel::openSendTransactionSheet, + onDisconnectSession = viewModel::disconnectSession, + onToggleWalletSwitcher = viewModel::toggleWalletSwitcher, + onSwitchWallet = viewModel::switchWallet, + onRemoveWallet = viewModel::removeWallet, + onRenameWallet = viewModel::renameWallet, + onImportWallet = viewModel::importWallet, + onGenerateWallet = viewModel::generateWallet, + onApproveConnect = viewModel::approveConnect, + onRejectConnect = viewModel::rejectConnect, + onApproveTransaction = viewModel::approveTransaction, + onRejectTransaction = viewModel::rejectTransaction, + onApproveSignData = viewModel::approveSignData, + onRejectSignData = viewModel::rejectSignData, + onTestSignDataText = viewModel::testSignDataText, + onTestSignDataBinary = viewModel::testSignDataBinary, + onTestSignDataCell = viewModel::testSignDataCell, + onTestSignDataWithSession = viewModel::testSignDataWithSession, + onTestSignDataBinaryWithSession = viewModel::testSignDataBinaryWithSession, + onTestSignDataCellWithSession = viewModel::testSignDataCellWithSession, + onSendTransaction = viewModel::sendTransaction, + onRefreshTransactions = viewModel::refreshTransactions, + onTransactionClick = viewModel::showTransactionDetail, + onHandleUrl = viewModel::handleTonConnectUrl, + onDismissUrlPrompt = viewModel::hideUrlPrompt, + ) + } + } + } + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/PerformanceActivity.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/PerformanceActivity.kt new file mode 100644 index 000000000..34c165f41 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/PerformanceActivity.kt @@ -0,0 +1,37 @@ +package io.ton.walletkit.demo.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import io.ton.walletkit.demo.PerformanceBenchmarkViewModel +import io.ton.walletkit.demo.PerformanceBenchmarkViewModelFactory +import io.ton.walletkit.demo.WalletKitDemoApp + +class PerformanceActivity : ComponentActivity() { + + private val viewModel: PerformanceBenchmarkViewModel by viewModels { + val app = application as WalletKitDemoApp + PerformanceBenchmarkViewModelFactory(app, app.storage) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.background) { + val benchmarkState by viewModel.state.collectAsState() + + PerformanceScreen( + benchmarkState = benchmarkState, + onRunBenchmark = viewModel::runBenchmark, + ) + } + } + } + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/PerformanceScreen.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/PerformanceScreen.kt new file mode 100644 index 000000000..7e340eafe --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/PerformanceScreen.kt @@ -0,0 +1,236 @@ +package io.ton.walletkit.demo.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.BenchmarkState +import io.ton.walletkit.demo.PerformanceCollector + +@Composable +fun PerformanceScreen( + benchmarkState: BenchmarkState, + onRunBenchmark: (engineKind: io.ton.walletkit.presentation.WalletKitEngineKind, runs: Int) -> Unit, +) { + val context = LocalContext.current + val metrics by PerformanceCollector.metrics.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "Performance Benchmark", + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Run multiple times to get accurate averages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Status indicator + if (benchmarkState.isRunning) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = benchmarkState.status, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + if (benchmarkState.totalRuns > 0) { + Text( + text = "Progress: ${benchmarkState.currentRun}/${benchmarkState.totalRuns}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "QuickJS Benchmark", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { onRunBenchmark(io.ton.walletkit.presentation.WalletKitEngineKind.QUICKJS, 1) }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("1 Run") + } + Button( + onClick = { onRunBenchmark(io.ton.walletkit.presentation.WalletKitEngineKind.QUICKJS, 3) }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("3 Runs") + } + Button( + onClick = { onRunBenchmark(io.ton.walletkit.presentation.WalletKitEngineKind.QUICKJS, 5) }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("5 Runs") + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "WebView Benchmark", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { onRunBenchmark(io.ton.walletkit.presentation.WalletKitEngineKind.WEBVIEW, 1) }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("1 Run") + } + Button( + onClick = { onRunBenchmark(io.ton.walletkit.presentation.WalletKitEngineKind.WEBVIEW, 3) }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("3 Runs") + } + Button( + onClick = { onRunBenchmark(io.ton.walletkit.presentation.WalletKitEngineKind.WEBVIEW, 5) }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("5 Runs") + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { PerformanceCollector.clear() }, + modifier = Modifier.weight(1f), + enabled = !benchmarkState.isRunning, + ) { + Text("Clear") + } + OutlinedButton( + onClick = { + val text = PerformanceCollector.getStats() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Performance Results", text) + clipboard.setPrimaryClip(clip) + // You could show a Toast here if you want + }, + modifier = Modifier.weight(1f), + enabled = metrics.isNotEmpty() && !benchmarkState.isRunning, + ) { + Text("Copy Text") + } + Button( + onClick = { + val csv = PerformanceCollector.exportCsv() + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, csv) + type = "text/csv" + } + context.startActivity(Intent.createChooser(sendIntent, "Share CSV")) + }, + modifier = Modifier.weight(1f), + enabled = metrics.isNotEmpty() && !benchmarkState.isRunning, + ) { + Text("Share CSV") + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (metrics.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Results (${metrics.size} runs)", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = PerformanceCollector.getStats(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + } + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/CodeBlock.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/CodeBlock.kt new file mode 100644 index 000000000..15f58fcda --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/CodeBlock.kt @@ -0,0 +1,34 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun CodeBlock(content: String) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = content, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CodeBlockPreview() { + CodeBlock(content = "{\n \"message\": \"Hello\"\n}") +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/EmptyStateCard.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/EmptyStateCard.kt new file mode 100644 index 000000000..60a256e97 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/EmptyStateCard.kt @@ -0,0 +1,44 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyStateCard(title: String, description: String) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyStateCardPreview() { + EmptyStateCard( + title = "No wallets", + description = "Import or generate a wallet to get started.", + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/NetworkBadge.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/NetworkBadge.kt new file mode 100644 index 000000000..83b54ed14 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/NetworkBadge.kt @@ -0,0 +1,37 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.TonNetwork + +@Composable +fun NetworkBadge(network: TonNetwork) { + val color = when (network) { + TonNetwork.MAINNET -> Color(0xFF2E7D32) + TonNetwork.TESTNET -> Color(0xFFF57C00) + } + Surface(shape = MaterialTheme.shapes.medium, color = color.copy(alpha = 0.12f)) { + Text( + text = when (network) { + TonNetwork.MAINNET -> "Mainnet" + TonNetwork.TESTNET -> "Testnet" + }, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + color = color, + style = MaterialTheme.typography.labelMedium, + ) + } +} + +@Preview +@Composable +private fun NetworkBadgePreview() { + NetworkBadge(network = TonNetwork.MAINNET) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/QuickActionsCard.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/QuickActionsCard.kt new file mode 100644 index 000000000..e74d61157 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/QuickActionsCard.kt @@ -0,0 +1,41 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun QuickActionsCard( + onHandleUrl: () -> Unit, + onAddWallet: () -> Unit, + onRefresh: () -> Unit, +) { + ElevatedCard { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Quick Actions", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FilledTonalButton(onClick = onHandleUrl) { Text("Handle URL") } + FilledTonalButton(onClick = onAddWallet) { Text("Add Wallet") } + FilledTonalButton(onClick = onRefresh) { Text("Refresh") } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun QuickActionsCardPreview() { + QuickActionsCard(onHandleUrl = {}, onAddWallet = {}, onRefresh = {}) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/SessionCard.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/SessionCard.kt new file mode 100644 index 000000000..d302d23a3 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/SessionCard.kt @@ -0,0 +1,70 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.SessionSummary +import io.ton.walletkit.demo.ui.preview.PreviewData +import io.ton.walletkit.demo.util.abbreviated + +@Composable +fun SessionCard(session: SessionSummary, onDisconnect: () -> Unit) { + ElevatedCard { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(session.dAppName, style = MaterialTheme.typography.titleMedium) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Wallet", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(session.walletAddress.abbreviated(), style = MaterialTheme.typography.bodyMedium) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val resolvedUrl = session.dAppUrl?.takeIf { it.isNotBlank() } + ?: session.manifestUrl?.takeIf { it.isNotBlank() } + resolvedUrl?.let { + Text( + "URL", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + it, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + TextButton(onClick = onDisconnect) { Text("Disconnect") } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SessionCardPreview() { + SessionCard(session = PreviewData.session, onDisconnect = {}) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/StatusHeader.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/StatusHeader.kt new file mode 100644 index 000000000..10c12c8f0 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/StatusHeader.kt @@ -0,0 +1,48 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.state.WalletUiState +import io.ton.walletkit.demo.ui.preview.PreviewData + +@Composable +fun StatusHeader(state: WalletUiState) { + val clipboardManager = LocalClipboardManager.current + + // Auto-copy to clipboard when clipboardContent is set + LaunchedEffect(state.clipboardContent) { + state.clipboardContent?.let { content -> + clipboardManager.setText(AnnotatedString(content)) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = state.status.ifBlank { "" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + if (state.lastUpdated != null) { + Text( + text = "Updated just now", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun StatusHeaderPreview() { + StatusHeader(state = PreviewData.uiState) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/WalletCard.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/WalletCard.kt new file mode 100644 index 000000000..4026fe7a6 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/WalletCard.kt @@ -0,0 +1,120 @@ +package io.ton.walletkit.demo.ui.components + +import android.content.ClipData +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.ui.preview.PreviewData +import io.ton.walletkit.demo.util.abbreviated +import kotlinx.coroutines.launch + +@Composable +fun WalletCard( + wallet: WalletSummary, + onDetails: () -> Unit, + onSend: () -> Unit = {}, +) { + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + ElevatedCard { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(wallet.name, style = MaterialTheme.typography.titleMedium) + NetworkBadge(wallet.network) + } + TextButton(onClick = onDetails) { Text("Details") } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Address", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = wallet.address.abbreviated(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + coroutineScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText("wallet_address", wallet.address)), + ) + } + }, + ) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy address") + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Balance", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(wallet.balance ?: "—", style = MaterialTheme.typography.headlineSmall) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onSend, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text("Send") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WalletCardPreview() { + WalletCard(wallet = PreviewData.wallet, onDetails = {}, onSend = {}) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/WalletSwitcher.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/WalletSwitcher.kt new file mode 100644 index 000000000..debab6780 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/components/WalletSwitcher.kt @@ -0,0 +1,358 @@ +package io.ton.walletkit.demo.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.TonNetwork +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.ui.preview.PreviewData + +@Composable +fun WalletSwitcher( + wallets: List, + activeWalletAddress: String?, + isExpanded: Boolean, + onToggle: () -> Unit, + onSwitchWallet: (String) -> Unit, + onRemoveWallet: (String) -> Unit, + onRenameWallet: (String, String) -> Unit, + modifier: Modifier = Modifier, +) { + var editingWalletAddress by rememberSaveable { mutableStateOf(null) } + var editingName by rememberSaveable { mutableStateOf("") } + var walletToDelete by remember { mutableStateOf(null) } + + val activeWallet = wallets.firstOrNull { it.address == activeWalletAddress } + + fun startEdit(wallet: WalletSummary) { + editingWalletAddress = wallet.address + editingName = wallet.name + } + + fun saveEdit() { + editingWalletAddress?.let { address -> + if (editingName.isNotBlank()) { + onRenameWallet(address, editingName.trim()) + } + editingWalletAddress = null + editingName = "" + } + } + + fun cancelEdit() { + editingWalletAddress = null + editingName = "" + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column { + // Active Wallet Header + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = activeWallet?.name ?: "No Wallet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = activeWallet?.let { formatAddress(it.address) } ?: "Select a wallet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (wallets.size > 1) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${wallets.size} wallets", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + IconButton(onClick = onToggle) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.rotate(if (isExpanded) 180f else 0f), + ) + } + } + } + } + + // Wallet List (when expanded) + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + wallets.forEach { wallet -> + val isActive = wallet.address == activeWalletAddress + val isEditing = editingWalletAddress == wallet.address + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors( + containerColor = if (isActive) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surface + }, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + if (isEditing) { + // Edit Mode + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedTextField( + value = editingName, + onValueChange = { editingName = it }, + label = { Text("Wallet name") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { saveEdit() }) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Save", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { cancelEdit() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + ) + } + } + } else { + // View Mode + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = wallet.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + if (isActive) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Active", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatAddress(wallet.address), + style = MaterialTheme.typography.bodySmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + wallet.balance?.let { balance -> + Text( + text = "$balance TON", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + } + } + + Row { + if (!isActive) { + IconButton(onClick = { onSwitchWallet(wallet.address) }) { + Icon( + imageVector = Icons.Default.SwapHoriz, + contentDescription = "Switch to this wallet", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + IconButton(onClick = { startEdit(wallet) }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Rename wallet", + ) + } + IconButton(onClick = { walletToDelete = wallet }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove wallet", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } + } + } + } + } + + // Delete Confirmation Dialog + walletToDelete?.let { wallet -> + AlertDialog( + onDismissRequest = { walletToDelete = null }, + title = { Text("Remove Wallet?") }, + text = { + Column { + Text("Are you sure you want to remove this wallet?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = wallet.name, + fontWeight = FontWeight.Bold, + ) + Text( + text = formatAddress(wallet.address), + style = MaterialTheme.typography.bodySmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + if (wallet.address == activeWalletAddress && wallets.size > 1) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will switch to another wallet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onRemoveWallet(wallet.address) + walletToDelete = null + }, + ) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { walletToDelete = null }) { + Text("Cancel") + } + }, + ) + } +} + +private fun formatAddress(address: String): String = if (address.length > 12) { + "${address.take(6)}...${address.takeLast(4)}" +} else { + address +} + +@Preview(showBackground = true) +@Composable +private fun WalletSwitcherPreview() { + val wallets = listOf( + PreviewData.wallet, + PreviewData.wallet.copy( + address = "EQD9876543210", + name = "Wallet 2", + balance = "123.45", + ), + PreviewData.wallet.copy( + address = "EQD1111111111", + name = "Wallet 3", + balance = "0.50", + ), + ) + + WalletSwitcher( + wallets = wallets, + activeWalletAddress = wallets.first().address, + isExpanded = true, + onToggle = {}, + onSwitchWallet = {}, + onRemoveWallet = {}, + onRenameWallet = { _, _ -> }, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/dialog/UrlPromptDialog.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/dialog/UrlPromptDialog.kt new file mode 100644 index 000000000..c231a45e1 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/dialog/UrlPromptDialog.kt @@ -0,0 +1,47 @@ +package io.ton.walletkit.demo.ui.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun UrlPromptDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var url by rememberSaveable { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button( + onClick = { + onConfirm(url) + url = "" + }, + enabled = url.isNotBlank(), + ) { Text("Handle") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + title = { Text("Handle TON Connect URL") }, + text = { + TextField( + value = url, + onValueChange = { url = it }, + placeholder = { Text("https:// or ton://") }, + singleLine = true, + ) + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun UrlPromptDialogPreview() { + UrlPromptDialog(onDismiss = {}, onConfirm = {}) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/preview/PreviewData.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/preview/PreviewData.kt new file mode 100644 index 000000000..f977d6dfc --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/preview/PreviewData.kt @@ -0,0 +1,92 @@ +package io.ton.walletkit.demo.ui.preview + +import io.ton.walletkit.demo.model.ConnectPermissionUi +import io.ton.walletkit.demo.model.ConnectRequestUi +import io.ton.walletkit.demo.model.SessionSummary +import io.ton.walletkit.demo.model.SignDataRequestUi +import io.ton.walletkit.demo.model.TonNetwork +import io.ton.walletkit.demo.model.TransactionMessageUi +import io.ton.walletkit.demo.model.TransactionRequestUi +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.state.SheetState +import io.ton.walletkit.demo.state.WalletUiState +import org.json.JSONObject + +object PreviewData { + val wallet: WalletSummary = WalletSummary( + address = "0:previewaddress", + name = "Preview Wallet", + network = TonNetwork.MAINNET, + version = "v5r1", + publicKey = "preview-public-key", + balanceNano = "123456789", + balance = "0.1234", + transactions = null, + lastUpdated = System.currentTimeMillis(), + ) + + val session: SessionSummary = SessionSummary( + sessionId = "session-preview", + dAppName = "Preview dApp", + walletAddress = wallet.address, + dAppUrl = "https://preview.app", + manifestUrl = "https://preview.app/manifest", + iconUrl = null, + createdAt = System.currentTimeMillis(), + lastActivity = System.currentTimeMillis(), + ) + + val connectRequest: ConnectRequestUi = ConnectRequestUi( + id = "request-preview", + dAppName = session.dAppName, + dAppUrl = session.dAppUrl.orEmpty(), + manifestUrl = session.manifestUrl.orEmpty(), + iconUrl = null, + permissions = listOf( + ConnectPermissionUi(name = "ton_addr", title = "Wallet address", description = "Access to your wallet address"), + ), + requestedItems = listOf("ton_proof"), + raw = JSONObject(mapOf("id" to "request-preview")), + ) + + val transactionRequest: TransactionRequestUi = TransactionRequestUi( + id = "tx-preview", + walletAddress = wallet.address, + dAppName = session.dAppName, + validUntil = null, + messages = listOf( + TransactionMessageUi( + to = "0:recipient", + amount = "1000000000", + comment = null, + payload = "payload", + stateInit = null, + ), + ), + preview = "{\n \"action\": \"transfer\"\n}", + raw = JSONObject(mapOf("id" to "tx-preview")), + ) + + val signDataRequest: SignDataRequestUi = SignDataRequestUi( + id = "sign-preview", + walletAddress = wallet.address, + payloadType = "ton_proof", + payloadContent = "{\n \"domain\": \"preview\"\n}", + preview = null, + raw = JSONObject(mapOf("id" to "sign-preview")), + ) + + val uiState: WalletUiState = WalletUiState( + initialized = true, + status = "WalletKit ready", + wallets = listOf(wallet), + sessions = listOf(session), + sheetState = SheetState.None, + isUrlPromptVisible = false, + isLoadingWallets = false, + isLoadingSessions = false, + error = null, + events = listOf("Handled TON Connect URL", "Approved transaction"), + lastUpdated = System.currentTimeMillis(), + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/screen/SendTransactionScreen.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/screen/SendTransactionScreen.kt new file mode 100644 index 000000000..215264a7d --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/screen/SendTransactionScreen.kt @@ -0,0 +1,196 @@ +package io.ton.walletkit.demo.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.WalletSummary + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendTransactionScreen( + wallet: WalletSummary, + onBack: () -> Unit, + onSend: (recipient: String, amount: String, comment: String) -> Unit, + error: String?, + isLoading: Boolean, +) { + var recipient by remember { mutableStateOf("") } + var amount by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } + val snackbarHostState = remember { SnackbarHostState() } + val scrollState = rememberScrollState() + + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar(it) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Send TON", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(scrollState) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Wallet Info + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "From", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + wallet.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Balance: ${wallet.balance ?: "0"} TON", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Recipient Address + OutlinedTextField( + value = recipient, + onValueChange = { recipient = it }, + label = { Text("Recipient Address") }, + placeholder = { Text("EQ... or UQ...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = recipient.isNotEmpty() && !isValidAddress(recipient), + supportingText = { + if (recipient.isNotEmpty() && !isValidAddress(recipient)) { + Text("Invalid TON address") + } + }, + ) + + // Amount + OutlinedTextField( + value = amount, + onValueChange = { amount = it }, + label = { Text("Amount (TON)") }, + placeholder = { Text("0.00") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + isError = amount.isNotEmpty() && !isValidAmount(amount), + supportingText = { + if (amount.isNotEmpty() && !isValidAmount(amount)) { + Text("Invalid amount") + } else if (amount.isNotEmpty() && isAmountTooLarge(amount, wallet.balance)) { + Text("Insufficient balance") + } + }, + ) + + // Comment (Optional) + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment (Optional)") }, + placeholder = { Text("Add a message...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 3, + supportingText = { + Text("This message will be visible on the blockchain") + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Send Button + Button( + onClick = { onSend(recipient, amount, comment) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading && + recipient.isNotEmpty() && + isValidAddress(recipient) && + amount.isNotEmpty() && + isValidAmount(amount) && + !isAmountTooLarge(amount, wallet.balance), + ) { + Text(if (isLoading) "Sending..." else "Send Transaction") + } + + // Info + Text( + "This will create and send a transaction from your wallet. " + + "You will be asked to approve it before it's sent to the network.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun isValidAddress(address: String): Boolean = address.length > 10 && (address.startsWith("EQ") || address.startsWith("UQ")) + +private fun isValidAmount(amount: String): Boolean { + return runCatching { + val value = amount.toDoubleOrNull() ?: return false + value > 0 + }.getOrDefault(false) +} + +private fun isAmountTooLarge(amount: String, balance: String?): Boolean { + return runCatching { + val amountValue = amount.toDoubleOrNull() ?: return false + val balanceValue = balance?.toDoubleOrNull() ?: return true + amountValue > balanceValue + }.getOrDefault(true) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/screen/WalletScreen.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/screen/WalletScreen.kt new file mode 100644 index 000000000..40b4fcda2 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/screen/WalletScreen.kt @@ -0,0 +1,301 @@ +package io.ton.walletkit.demo.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.ConnectRequestUi +import io.ton.walletkit.demo.model.SignDataRequestUi +import io.ton.walletkit.demo.model.TonNetwork +import io.ton.walletkit.demo.model.TransactionRequestUi +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.state.SheetState +import io.ton.walletkit.demo.state.WalletUiState +import io.ton.walletkit.demo.ui.components.QuickActionsCard +import io.ton.walletkit.demo.ui.components.StatusHeader +import io.ton.walletkit.demo.ui.components.WalletSwitcher +import io.ton.walletkit.demo.ui.dialog.UrlPromptDialog +import io.ton.walletkit.demo.ui.preview.PreviewData +import io.ton.walletkit.demo.ui.screen.SendTransactionScreen +import io.ton.walletkit.demo.ui.sections.EventLogSection +import io.ton.walletkit.demo.ui.sections.SessionsSection +import io.ton.walletkit.demo.ui.sections.TransactionHistorySection +import io.ton.walletkit.demo.ui.sections.WalletsSection +import io.ton.walletkit.demo.ui.sheet.AddWalletSheet +import io.ton.walletkit.demo.ui.sheet.ConnectRequestSheet +import io.ton.walletkit.demo.ui.sheet.SignDataSheet +import io.ton.walletkit.demo.ui.sheet.TransactionRequestSheet +import io.ton.walletkit.demo.ui.sheet.WalletDetailsSheet + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletScreen( + state: WalletUiState, + onAddWalletClick: () -> Unit, + onUrlPromptClick: () -> Unit, + onRefresh: () -> Unit, + onDismissSheet: () -> Unit, + onWalletDetails: (String) -> Unit, + onSendFromWallet: (String) -> Unit, + onDisconnectSession: (String) -> Unit, + onToggleWalletSwitcher: () -> Unit, + onSwitchWallet: (String) -> Unit, + onRemoveWallet: (String) -> Unit, + onRenameWallet: (String, String) -> Unit, + onImportWallet: (String, TonNetwork, List, String) -> Unit, + onGenerateWallet: (String, TonNetwork, String) -> Unit, + onApproveConnect: (ConnectRequestUi, WalletSummary) -> Unit, + onRejectConnect: (ConnectRequestUi) -> Unit, + onApproveTransaction: (TransactionRequestUi) -> Unit, + onRejectTransaction: (TransactionRequestUi) -> Unit, + onApproveSignData: (SignDataRequestUi) -> Unit, + onRejectSignData: (SignDataRequestUi) -> Unit, + onTestSignDataText: (String) -> Unit, + onTestSignDataBinary: (String) -> Unit, + onTestSignDataCell: (String) -> Unit, + onTestSignDataWithSession: (String, String) -> Unit, + onTestSignDataBinaryWithSession: (String, String) -> Unit, + onTestSignDataCellWithSession: (String, String) -> Unit, + onSendTransaction: (walletAddress: String, recipient: String, amount: String, comment: String) -> Unit, + onRefreshTransactions: (String) -> Unit, + onTransactionClick: (transactionHash: String, walletAddress: String) -> Unit, + onHandleUrl: (String) -> Unit, + onDismissUrlPrompt: () -> Unit, +) { + val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(state.error) { + val error = state.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(error) + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheet = state.sheetState + val showSheet = sheet !is SheetState.None + val activeWallet = state.wallets.firstOrNull { it.address == state.activeWalletAddress } + ?: state.wallets.firstOrNull() + if (showSheet) { + ModalBottomSheet( + onDismissRequest = onDismissSheet, + sheetState = sheetState, + dragHandle = null, + ) { + when (sheet) { + SheetState.AddWallet -> AddWalletSheet( + onDismiss = onDismissSheet, + onImportWallet = onImportWallet, + onGenerateWallet = onGenerateWallet, + walletCount = state.wallets.size, + ) + + is SheetState.Connect -> ConnectRequestSheet( + request = sheet.request, + wallets = state.wallets, + onApprove = { wallet -> onApproveConnect(sheet.request, wallet) }, + onReject = { onRejectConnect(sheet.request) }, + ) + + is SheetState.Transaction -> TransactionRequestSheet( + request = sheet.request, + onApprove = { onApproveTransaction(sheet.request) }, + onReject = { onRejectTransaction(sheet.request) }, + ) + + is SheetState.SignData -> SignDataSheet( + request = sheet.request, + onApprove = { onApproveSignData(sheet.request) }, + onReject = { onRejectSignData(sheet.request) }, + ) + + is SheetState.WalletDetails -> WalletDetailsSheet( + wallet = sheet.wallet, + onDismiss = onDismissSheet, + onTestSignDataText = onTestSignDataText, + onTestSignDataBinary = onTestSignDataBinary, + onTestSignDataCell = onTestSignDataCell, + onTestSignDataWithSession = onTestSignDataWithSession, + onTestSignDataBinaryWithSession = onTestSignDataBinaryWithSession, + onTestSignDataCellWithSession = onTestSignDataCellWithSession, + ) + + is SheetState.SendTransaction -> SendTransactionScreen( + wallet = sheet.wallet, + onBack = onDismissSheet, + onSend = { recipient, amount, comment -> + onSendTransaction(sheet.wallet.address, recipient, amount, comment) + }, + error = state.error, + isLoading = state.isSendingTransaction, + ) + + is SheetState.TransactionDetail -> io.ton.walletkit.demo.ui.sheet.TransactionDetailSheet( + transaction = sheet.transaction, + onDismiss = onDismissSheet, + ) + + SheetState.None -> Unit + } + } + } + + if (state.isUrlPromptVisible) { + UrlPromptDialog( + onDismiss = onDismissUrlPrompt, + onConfirm = onHandleUrl, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "TonWallet Demo", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + IconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + IconButton(onClick = onUrlPromptClick) { + Icon(Icons.Outlined.Link, contentDescription = "Handle URL") + } + IconButton(onClick = onAddWalletClick) { + Icon(Icons.Default.Add, contentDescription = "Add Wallet") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(scrollState) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + if (state.isLoadingWallets || state.isLoadingSessions) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + StatusHeader(state) + + QuickActionsCard( + onHandleUrl = onUrlPromptClick, + onAddWallet = onAddWalletClick, + onRefresh = onRefresh, + ) + + // Wallet Switcher (only show if multiple wallets exist) + if (state.wallets.size > 1) { + WalletSwitcher( + wallets = state.wallets, + activeWalletAddress = state.activeWalletAddress, + isExpanded = state.isWalletSwitcherExpanded, + onToggle = onToggleWalletSwitcher, + onSwitchWallet = onSwitchWallet, + onRemoveWallet = onRemoveWallet, + onRenameWallet = onRenameWallet, + ) + } + + WalletsSection( + activeWallet = activeWallet, + totalWallets = state.wallets.size, + onWalletSelected = onWalletDetails, + onSendFromWallet = onSendFromWallet, + ) + + // Show transaction history for the active wallet + activeWallet?.let { wallet -> + TransactionHistorySection( + transactions = wallet.transactions, + walletAddress = wallet.address, + isRefreshing = state.isLoadingTransactions, + onRefreshTransactions = { onRefreshTransactions(wallet.address) }, + onTransactionClick = { hash -> onTransactionClick(hash, wallet.address) }, + ) + } + + SessionsSection( + sessions = state.sessions, + onDisconnect = onDisconnectSession, + ) + + if (state.events.isNotEmpty()) { + EventLogSection(events = state.events) + } + + Spacer(modifier = Modifier.height(48.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WalletScreenPreview() { + WalletScreen( + state = PreviewData.uiState, + onAddWalletClick = {}, + onUrlPromptClick = {}, + onRefresh = {}, + onDismissSheet = {}, + onWalletDetails = {}, + onSendFromWallet = {}, + onDisconnectSession = {}, + onToggleWalletSwitcher = {}, + onSwitchWallet = {}, + onRemoveWallet = {}, + onRenameWallet = { _, _ -> }, + onImportWallet = { _, _, _, _ -> }, + onGenerateWallet = { _, _, _ -> }, + onApproveConnect = { _, _ -> }, + onRejectConnect = {}, + onApproveTransaction = {}, + onRejectTransaction = {}, + onApproveSignData = {}, + onRejectSignData = {}, + onTestSignDataText = {}, + onTestSignDataBinary = {}, + onTestSignDataCell = {}, + onTestSignDataWithSession = { _, _ -> }, + onTestSignDataBinaryWithSession = { _, _ -> }, + onTestSignDataCellWithSession = { _, _ -> }, + onSendTransaction = { _, _, _, _ -> }, + onRefreshTransactions = {}, + onTransactionClick = { _, _ -> }, + onHandleUrl = {}, + onDismissUrlPrompt = {}, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/EventLogSection.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/EventLogSection.kt new file mode 100644 index 000000000..6af22776c --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/EventLogSection.kt @@ -0,0 +1,27 @@ +package io.ton.walletkit.demo.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun EventLogSection(events: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Recent events", style = MaterialTheme.typography.titleMedium) + events.forEach { event -> + HorizontalDivider() + Text(event, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EventLogSectionPreview() { + EventLogSection(events = listOf("Handled TON Connect URL", "Approved transaction")) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/SessionsSection.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/SessionsSection.kt new file mode 100644 index 000000000..23a74ce38 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/SessionsSection.kt @@ -0,0 +1,58 @@ +package io.ton.walletkit.demo.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.SessionSummary +import io.ton.walletkit.demo.ui.components.EmptyStateCard +import io.ton.walletkit.demo.ui.components.SessionCard +import io.ton.walletkit.demo.ui.preview.PreviewData + +@Composable +fun SessionsSection(sessions: List, onDisconnect: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Active Sessions", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "${sessions.size}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + if (sessions.isEmpty()) { + EmptyStateCard( + title = "No active sessions", + description = "Use TON Connect to pair with a dApp.", + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + sessions.forEach { session -> + SessionCard( + session = session, + onDisconnect = { onDisconnect(session.sessionId) }, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SessionsSectionPreview() { + SessionsSection(sessions = listOf(PreviewData.session), onDisconnect = {}) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/TransactionHistorySection.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/TransactionHistorySection.kt new file mode 100644 index 000000000..7986ff02f --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/TransactionHistorySection.kt @@ -0,0 +1,250 @@ +package io.ton.walletkit.demo.ui.sections + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallMade +import androidx.compose.material.icons.automirrored.filled.CallReceived +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.ui.components.EmptyStateCard +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.TransactionType +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun TransactionHistorySection( + transactions: List?, + walletAddress: String, + isRefreshing: Boolean, + onRefreshTransactions: () -> Unit, + onTransactionClick: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Recent Transactions", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(8.dp)) + transactions?.let { + Text( + "${it.size}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + TextButton( + onClick = onRefreshTransactions, + enabled = !isRefreshing, + ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp, + ) + } + Text(if (isRefreshing) "Refreshing…" else "Refresh") + } + } + + if (transactions.isNullOrEmpty()) { + EmptyStateCard( + title = "No transactions", + description = "Your transaction history will appear here.", + ) + } else { + // Use Column with key() composable for efficient recomposition + // Limited to 10 items, so no need for LazyColumn (which would cause infinite constraints) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (tx in transactions.take(10)) { + // Use key() to provide stable identity for efficient recomposition + key(tx.hash) { + TransactionItem( + transaction = tx, + walletAddress = walletAddress, + onClick = { onTransactionClick(tx.hash) }, + ) + } + } + } + } + } +} + +@Composable +private fun TransactionItem( + transaction: Transaction, + walletAddress: String, + onClick: () -> Unit, +) { + val isOutgoing = transaction.type == TransactionType.OUTGOING + val amount = formatNanoTon(transaction.amount) + val timestamp = transaction.timestamp + val hash = transaction.hash + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Icon + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (isOutgoing) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isOutgoing) { + Icons.AutoMirrored.Filled.CallMade + } else { + Icons.AutoMirrored.Filled.CallReceived + }, + contentDescription = if (isOutgoing) "Sent" else "Received", + tint = if (isOutgoing) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + }, + modifier = Modifier.size(20.dp), + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Transaction details + Column(modifier = Modifier.weight(1f)) { + Text( + if (isOutgoing) "Sent" else "Received", + style = MaterialTheme.typography.titleSmall, + ) + Text( + formatTimestamp(timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (hash.isNotEmpty()) { + Text( + hash.take(16) + "...", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Amount + Text( + "${if (isOutgoing) "-" else "+"}$amount TON", + style = MaterialTheme.typography.titleMedium, + color = if (isOutgoing) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + } + } +} + +private fun isOutgoingTransaction(transaction: JSONObject, walletAddress: String): Boolean { + // Check if the transaction has outgoing messages + val outMsgs = transaction.optJSONArray("out_msgs") + return outMsgs != null && outMsgs.length() > 0 +} + +private fun getTransactionAmount(transaction: JSONObject, isOutgoing: Boolean): String = try { + if (isOutgoing) { + // Sum all outgoing messages + val outMsgs = transaction.optJSONArray("out_msgs") + var total = 0L + if (outMsgs != null) { + for (i in 0 until outMsgs.length()) { + val msg = outMsgs.optJSONObject(i) + val value = msg?.optString("value")?.toLongOrNull() ?: 0L + total += value + } + } + formatNanoTon(total.toString()) + } else { + // Get incoming message value + val inMsg = transaction.optJSONObject("in_msg") + val value = inMsg?.optString("value") ?: "0" + formatNanoTon(value) + } +} catch (e: Exception) { + "0.0000" +} + +private fun formatNanoTon(nanoTon: String): String = try { + val value = nanoTon.toLongOrNull() ?: 0L + val ton = value.toDouble() / 1_000_000_000.0 + String.format(Locale.US, "%.4f", ton) +} catch (e: Exception) { + "0.0000" +} + +private fun formatTimestamp(timestamp: Long): String = try { + if (timestamp == 0L) { + "Unknown time" + } else { + // Timestamp is already in milliseconds from the bridge + val date = Date(timestamp) + val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) + sdf.format(date) + } +} catch (e: Exception) { + "Unknown time" +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/WalletsSection.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/WalletsSection.kt new file mode 100644 index 000000000..4a3135ad6 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sections/WalletsSection.kt @@ -0,0 +1,76 @@ +package io.ton.walletkit.demo.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.ui.components.EmptyStateCard +import io.ton.walletkit.demo.ui.components.WalletCard +import io.ton.walletkit.demo.ui.preview.PreviewData + +@Composable +fun WalletsSection( + activeWallet: WalletSummary?, + totalWallets: Int, + onWalletSelected: (String) -> Unit, + onSendFromWallet: (String) -> Unit = {}, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Active Wallet", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when { + totalWallets == 0 -> "None" + totalWallets == 1 -> "1 wallet" + else -> "$totalWallets wallets" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + if (activeWallet == null) { + EmptyStateCard( + title = "No wallets", + description = "Import or generate a wallet to get started.", + ) + } else { + WalletCard( + wallet = activeWallet, + onDetails = { onWalletSelected(activeWallet.address) }, + onSend = { onSendFromWallet(activeWallet.address) }, + ) + if (totalWallets > 1) { + Text( + text = "Use the wallet switcher to view other wallets.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WalletsSectionPreview() { + WalletsSection( + activeWallet = PreviewData.wallet, + totalWallets = 3, + onWalletSelected = {}, + onSendFromWallet = {}, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/AddWalletSheet.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/AddWalletSheet.kt new file mode 100644 index 000000000..8ab309714 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/AddWalletSheet.kt @@ -0,0 +1,243 @@ +package io.ton.walletkit.demo.ui.sheet + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.TonNetwork + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AddWalletSheet( + onDismiss: () -> Unit, + onImportWallet: (String, TonNetwork, List, String) -> Unit, + onGenerateWallet: (String, TonNetwork, String) -> Unit, + walletCount: Int, +) { + var selectedTab by remember { mutableStateOf(AddWalletTab.Import) } + var walletName by rememberSaveable { mutableStateOf("Wallet ${walletCount + 1}") } + var network by rememberSaveable { mutableStateOf(TonNetwork.MAINNET) } + var walletVersion by rememberSaveable { mutableStateOf("v4r2") } + val mnemonicWords = remember { mutableStateListOf(*Array(24) { "" }) } + var pasteField by rememberSaveable { mutableStateOf("") } + val clipboardManager = LocalClipboardManager.current + + // Function to parse pasted text into individual words + fun parseSeedPhrase(text: String) { + val words = text + .trim() + .lowercase() + .split(Regex("\\s+")) // Split by any whitespace (space, tab, newline) + .filter { it.isNotBlank() } + .take(24) // Only take first 24 words + + // Clear existing words + for (i in mnemonicWords.indices) { + mnemonicWords[i] = "" + } + + // Fill in the parsed words + words.forEachIndexed { index, word -> + if (index < 24) { + mnemonicWords[index] = word + } + } + + // Clear paste field after parsing + pasteField = "" + } + + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Add Wallet", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + SingleChoiceSegmentedButtonRow { + AddWalletTab.entries.forEachIndexed { index, tab -> + SegmentedButton( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + shape = SegmentedButtonDefaults.itemShape(index, AddWalletTab.entries.size), + label = { Text(tab.label) }, + ) + } + } + + TextField( + value = walletName, + onValueChange = { walletName = it }, + label = { Text("Wallet name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Text("Network", style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TonNetwork.entries.forEach { option -> + FilterChip( + selected = network == option, + onClick = { network = option }, + label = { Text(if (option == TonNetwork.MAINNET) "Mainnet" else "Testnet") }, + ) + } + } + + Text("Wallet Version", style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("v4r2", "v5r1", "v3r2").forEach { version -> + FilterChip( + selected = walletVersion == version, + onClick = { walletVersion = version }, + label = { + Column { + Text(version, fontWeight = FontWeight.Bold) + if (version == "v4r2") { + Text("Default", style = MaterialTheme.typography.labelSmall) + } + } + }, + ) + } + } + + when (selectedTab) { + AddWalletTab.Import -> { + Text("Recovery phrase (24 words)", style = MaterialTheme.typography.titleSmall) + + // Paste field for quick input + OutlinedTextField( + value = pasteField, + onValueChange = { + pasteField = it + // Auto-parse when text is pasted (contains multiple words) + if (it.trim().split(Regex("\\s+")).size > 1) { + parseSeedPhrase(it) + } + }, + label = { Text("Paste all 24 words here") }, + placeholder = { Text("word1 word2 word3 ... word24") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 3, + trailingIcon = { + IconButton( + onClick = { + clipboardManager.getText()?.text?.let { clipboardText -> + parseSeedPhrase(clipboardText) + } + }, + ) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = "Paste from clipboard", + ) + } + }, + supportingText = { + Text( + "Paste all words at once, or fill individually below", + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + userScrollEnabled = false, + modifier = Modifier.heightIn(max = 600.dp), + ) { + itemsIndexed(mnemonicWords) { index, word -> + TextField( + value = word, + onValueChange = { mnemonicWords[index] = it.lowercase().trim() }, + singleLine = true, + label = { Text("${index + 1}") }, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall, + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { onImportWallet(walletName, network, mnemonicWords.toList(), walletVersion) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Import wallet") } + } + + AddWalletTab.Generate -> { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Generate a demo wallet using a mock mnemonic phrase. Store it securely if you intend to reuse it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { onGenerateWallet(walletName, network, walletVersion) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Generate wallet") } + } + } + } + + TextButton(onClick = onDismiss) { Text("Cancel") } + } +} + +private enum class AddWalletTab(val label: String) { + Import("Import"), + Generate("Generate"), +} + +@Preview(showBackground = true) +@Composable +private fun AddWalletSheetPreview() { + AddWalletSheet( + onDismiss = {}, + onImportWallet = { _, _, _, _ -> }, + onGenerateWallet = { _, _, _ -> }, + walletCount = 1, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/ConnectRequestSheet.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/ConnectRequestSheet.kt new file mode 100644 index 000000000..0dc7525b1 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/ConnectRequestSheet.kt @@ -0,0 +1,129 @@ +package io.ton.walletkit.demo.ui.sheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.ConnectRequestUi +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.ui.preview.PreviewData +import io.ton.walletkit.demo.util.abbreviated + +@Composable +fun ConnectRequestSheet( + request: ConnectRequestUi, + wallets: List, + onApprove: (WalletSummary) -> Unit, + onReject: () -> Unit, +) { + var selectedWallet by remember { mutableStateOf(wallets.firstOrNull()) } + + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Connect Request", style = MaterialTheme.typography.titleLarge) + Text(request.dAppName, style = MaterialTheme.typography.titleMedium) + Text( + request.dAppUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (request.permissions.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Requested permissions", style = MaterialTheme.typography.titleSmall) + request.permissions.forEach { permission -> + AssistChip(onClick = {}, label = { Text(permission.title.ifBlank { permission.name }) }) + Text( + permission.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Select wallet", style = MaterialTheme.typography.titleSmall) + wallets.forEach { wallet -> + ElevatedCard( + modifier = Modifier.clickable { selectedWallet = wallet }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(wallet.name, style = MaterialTheme.typography.titleMedium) + Text(wallet.address.abbreviated(), style = MaterialTheme.typography.bodySmall) + } + RadioIndicator(selected = selectedWallet?.address == wallet.address) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onReject, modifier = Modifier.weight(1f)) { Text("Reject") } + Button( + onClick = { selectedWallet?.let(onApprove) }, + enabled = selectedWallet != null, + modifier = Modifier.weight(1f), + ) { Text("Connect") } + } + } +} + +@Composable +private fun RadioIndicator(selected: Boolean) { + Box( + modifier = Modifier + .size(20.dp) + .background( + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Box( + modifier = Modifier + .size(10.dp) + .background(MaterialTheme.colorScheme.onPrimary, CircleShape), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectRequestSheetPreview() { + ConnectRequestSheet( + request = PreviewData.connectRequest, + wallets = listOf(PreviewData.wallet), + onApprove = {}, + onReject = {}, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/SignDataSheet.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/SignDataSheet.kt new file mode 100644 index 000000000..6991f506f --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/SignDataSheet.kt @@ -0,0 +1,62 @@ +package io.ton.walletkit.demo.ui.sheet + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.SignDataRequestUi +import io.ton.walletkit.demo.ui.components.CodeBlock +import io.ton.walletkit.demo.ui.preview.PreviewData +import io.ton.walletkit.demo.util.abbreviated + +@Composable +fun SignDataSheet( + request: SignDataRequestUi, + onApprove: () -> Unit, + onReject: () -> Unit, +) { + val clipboardManager = LocalClipboardManager.current + + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Sign Data Request", style = MaterialTheme.typography.titleLarge) + Text("Wallet: ${request.walletAddress.abbreviated()}", style = MaterialTheme.typography.bodyMedium) + Text("Type: ${request.payloadType}", style = MaterialTheme.typography.bodyMedium) + + // Show preview if available (human-readable), otherwise show raw payload + Text("Data to Sign (tap to copy)", style = MaterialTheme.typography.titleSmall) + Column( + modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(request.payloadContent)) + }, + ) { + CodeBlock(request.preview ?: request.payloadContent) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onReject, modifier = Modifier.weight(1f)) { Text("Reject") } + Button(onClick = onApprove, modifier = Modifier.weight(1f)) { Text("Sign") } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SignDataSheetPreview() { + SignDataSheet( + request = PreviewData.signDataRequest, + onApprove = {}, + onReject = {}, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/TransactionDetailSheet.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/TransactionDetailSheet.kt new file mode 100644 index 000000000..a5e95ffc5 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/TransactionDetailSheet.kt @@ -0,0 +1,243 @@ +package io.ton.walletkit.demo.ui.sheet + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallMade +import androidx.compose.material.icons.automirrored.filled.CallReceived +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.TransactionDetailUi +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransactionDetailSheet( + transaction: TransactionDetailUi, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header with icon and amount + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background( + if (transaction.isOutgoing) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (transaction.isOutgoing) { + Icons.AutoMirrored.Filled.CallMade + } else { + Icons.AutoMirrored.Filled.CallReceived + }, + contentDescription = null, + tint = if (transaction.isOutgoing) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + }, + modifier = Modifier.size(32.dp), + ) + } + + Text( + if (transaction.isOutgoing) "Sent" else "Received", + style = MaterialTheme.typography.titleLarge, + ) + + Text( + "${if (transaction.isOutgoing) "-" else "+"}${transaction.amount} TON", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = if (transaction.isOutgoing) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + + Text( + formatFullTimestamp(transaction.timestamp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Transaction details card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DetailRow(label = "Status", value = transaction.status) + + HorizontalDivider() + + DetailRow(label = "Fee", value = "${transaction.fee} TON") + + if (transaction.fromAddress != null) { + HorizontalDivider() + DetailRow( + label = "From", + value = transaction.fromAddress, + isAddress = true, + ) + } + + if (transaction.toAddress != null) { + HorizontalDivider() + DetailRow( + label = "To", + value = transaction.toAddress, + isAddress = true, + ) + } + + if (!transaction.comment.isNullOrBlank()) { + HorizontalDivider() + DetailRow(label = "Comment", value = transaction.comment) + } + + HorizontalDivider() + + DetailRow( + label = "Transaction Hash", + value = transaction.hash, + isAddress = true, + ) + + HorizontalDivider() + + DetailRow(label = "Logical Time", value = transaction.lt) + + HorizontalDivider() + + DetailRow(label = "Block", value = transaction.blockSeqno.toString()) + } + } + + // View on Blockchain button + OutlinedButton( + onClick = { + val url = "https://tonviewer.com/transaction/${transaction.hash}" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("View on Blockchain Explorer") + } + + // Close button + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Close") + } + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + isAddress: Boolean = false, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isAddress) FontFamily.Monospace else FontFamily.Default, + ) + } +} + +private fun formatFullTimestamp(timestamp: Long): String = try { + if (timestamp == 0L) { + "Unknown time" + } else { + val date = Date(timestamp) // Already in milliseconds from bridge + val sdf = SimpleDateFormat("MMMM dd, yyyy 'at' HH:mm:ss", Locale.getDefault()) + sdf.format(date) + } +} catch (e: Exception) { + "Unknown time" +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/TransactionRequestSheet.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/TransactionRequestSheet.kt new file mode 100644 index 000000000..d30a3ce72 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/TransactionRequestSheet.kt @@ -0,0 +1,139 @@ +package io.ton.walletkit.demo.ui.sheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.TransactionRequestUi +import io.ton.walletkit.demo.ui.preview.PreviewData +import io.ton.walletkit.demo.util.abbreviated +import java.math.BigDecimal +import java.math.RoundingMode + +@Composable +fun TransactionRequestSheet( + request: TransactionRequestUi, + onApprove: () -> Unit, + onReject: () -> Unit, +) { + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + val isDirectSend = request.dAppName == "Unknown dApp" || request.dAppName.isBlank() + + Text( + if (isDirectSend) "Confirm Transaction" else "dApp Transaction Request", + style = MaterialTheme.typography.titleLarge, + ) + Text("From: ${request.walletAddress.abbreviated()}", style = MaterialTheme.typography.bodyMedium) + + if (!isDirectSend) { + Text("Requested by: ${request.dAppName}", style = MaterialTheme.typography.bodyMedium) + } + + request.validUntil?.let { Text("Valid until: $it", style = MaterialTheme.typography.bodyMedium) } + + if (request.messages.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + request.messages.forEachIndexed { index, message -> + Surface(shape = RoundedCornerShape(12.dp), tonalElevation = 2.dp) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("To:", style = MaterialTheme.typography.labelMedium) + Text( + message.to.abbreviated(), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Amount:", style = MaterialTheme.typography.labelMedium) + Text( + "${formatNanoToTon(message.amount)} TON", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + ) + } + + // Show comment if present + message.comment?.takeIf { it.isNotBlank() }?.let { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + "Comment:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + message.payload?.takeIf { it.isNotBlank() }?.let { + Text( + "Payload: ${it.take(50)}${if (it.length > 50) "..." else ""}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + } + + // Note: Fee estimation removed - only shown in completed transactions + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onReject, modifier = Modifier.weight(1f)) { Text("Reject") } + Button(onClick = onApprove, modifier = Modifier.weight(1f)) { Text("Approve") } + } + } +} + +private fun formatNanoToTon(nanotons: String): String = try { + BigDecimal(nanotons) + .divide(BigDecimal("1000000000"), 9, RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() +} catch (e: Exception) { + nanotons +} + +@Preview(showBackground = true) +@Composable +private fun TransactionRequestSheetPreview() { + TransactionRequestSheet( + request = PreviewData.transactionRequest, + onApprove = {}, + onReject = {}, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/WalletDetailsSheet.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/WalletDetailsSheet.kt new file mode 100644 index 000000000..c467e90f1 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/ui/sheet/WalletDetailsSheet.kt @@ -0,0 +1,407 @@ +package io.ton.walletkit.demo.ui.sheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.model.WalletSummary +import io.ton.walletkit.demo.ui.components.NetworkBadge +import io.ton.walletkit.demo.ui.preview.PreviewData + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletDetailsSheet( + wallet: WalletSummary, + onDismiss: () -> Unit, + onTestSignDataText: (String) -> Unit, + onTestSignDataBinary: (String) -> Unit, + onTestSignDataCell: (String) -> Unit, + onTestSignDataWithSession: (String, String) -> Unit, + onTestSignDataBinaryWithSession: (String, String) -> Unit, + onTestSignDataCellWithSession: (String, String) -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header with wallet icon and name + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.AccountBalanceWallet, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(32.dp), + ) + } + + Text( + text = wallet.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + NetworkBadge(wallet.network) + + // Balance + wallet.balance?.let { balance -> + Text( + text = "$balance TON", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Wallet Details Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DetailRow( + label = "Wallet Address", + value = wallet.address, + isMonospace = true, + ) + + HorizontalDivider() + + DetailRow( + label = "Wallet Version", + value = wallet.version, + ) + + wallet.publicKey?.let { pubKey -> + HorizontalDivider() + DetailRow( + label = "Public Key", + value = pubKey, + isMonospace = true, + ) + } + + wallet.transactions?.let { transactions -> + if (transactions.isNotEmpty()) { + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Transactions", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${transactions.size} total", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + } + + // Connected dApps Section + if (wallet.connectedSessions.isNotEmpty()) { + Text( + text = "Connected dApps", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + wallet.connectedSessions.forEachIndexed { index, session -> + if (index > 0) { + HorizontalDivider() + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = session.dAppName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + session.dAppUrl?.let { url -> + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + } + } + } + + // Test sign data with this session - three buttons for different types + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Text sign button + OutlinedButton( + onClick = { onTestSignDataWithSession(wallet.address, session.sessionId) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Sign Text with ${session.dAppName}") + } + + // Binary sign button + OutlinedButton( + onClick = { onTestSignDataBinaryWithSession(wallet.address, session.sessionId) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Sign Binary with ${session.dAppName}") + } + + // Cell sign button + OutlinedButton( + onClick = { onTestSignDataCellWithSession(wallet.address, session.sessionId) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.DataObject, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Sign Cell with ${session.dAppName}") + } + } + } + } + } + } else { + // No connected dApps + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.LinkOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Text( + text = "No connected dApps", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Sign Data Demo Section + Text( + text = "Sign Data Demo (Local)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) + + Text( + text = "Test signing different types of data", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Button( + onClick = { onTestSignDataText(wallet.address) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Sign Text Message") + } + + Button( + onClick = { onTestSignDataBinary(wallet.address) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Sign Binary Data") + } + + Button( + onClick = { onTestSignDataCell(wallet.address) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.DataObject, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Sign TON Cell") + } + + // Close button + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Close") + } + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + isMonospace: Boolean = false, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isMonospace) FontFamily.Monospace else FontFamily.Default, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WalletDetailsSheetPreview() { + WalletDetailsSheet( + wallet = PreviewData.wallet, + onDismiss = {}, + onTestSignDataText = {}, + onTestSignDataBinary = {}, + onTestSignDataCell = {}, + onTestSignDataWithSession = { _, _ -> }, + onTestSignDataBinaryWithSession = { _, _ -> }, + onTestSignDataCellWithSession = { _, _ -> }, + ) +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/util/StringExtensions.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/util/StringExtensions.kt new file mode 100644 index 000000000..dc3d41d2f --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/util/StringExtensions.kt @@ -0,0 +1,8 @@ +package io.ton.walletkit.demo.util + +fun String.abbreviated(length: Int = 6): String { + if (this.length <= length * 2) return this + val prefix = take(length) + val suffix = takeLast(length) + return "$prefix…$suffix" +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/util/TransactionDiffUtil.kt b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/util/TransactionDiffUtil.kt new file mode 100644 index 000000000..f4501de53 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/util/TransactionDiffUtil.kt @@ -0,0 +1,153 @@ +package io.ton.walletkit.demo.util + +import androidx.recyclerview.widget.DiffUtil +import io.ton.walletkit.domain.model.Transaction + +/** + * DiffUtil callback for efficiently calculating differences between transaction lists. + * Uses transaction hash as the unique identifier. + */ +class TransactionDiffCallback( + private val oldList: List, + private val newList: List, +) : DiffUtil.Callback() { + + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + /** + * Check if items represent the same transaction (same hash). + */ + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.hash == newItem.hash + } + + /** + * Check if the contents of the transaction are the same. + * This is called only when areItemsTheSame returns true. + */ + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + + // Compare all fields that might change + return oldItem.hash == newItem.hash && + oldItem.timestamp == newItem.timestamp && + oldItem.amount == newItem.amount && + oldItem.fee == newItem.fee && + oldItem.comment == newItem.comment && + oldItem.sender == newItem.sender && + oldItem.recipient == newItem.recipient && + oldItem.type == newItem.type && + oldItem.lt == newItem.lt && + oldItem.blockSeqno == newItem.blockSeqno + } + + /** + * Get the payload of changes between old and new items. + * This can be used for partial updates in the UI. + */ + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + + // Create a list of changed fields + val changes = mutableListOf() + + if (oldItem.amount != newItem.amount) changes.add("amount") + if (oldItem.fee != newItem.fee) changes.add("fee") + if (oldItem.comment != newItem.comment) changes.add("comment") + if (oldItem.type != newItem.type) changes.add("type") + if (oldItem.blockSeqno != newItem.blockSeqno) changes.add("blockSeqno") + + return if (changes.isNotEmpty()) changes else null + } +} + +/** + * Helper object for calculating transaction list diffs. + */ +object TransactionDiffUtil { + + /** + * Calculate the difference between two transaction lists. + * Returns a DiffUtil.DiffResult that can be used to update UI efficiently. + */ + fun calculateDiff( + oldList: List, + newList: List, + detectMoves: Boolean = true, + ): DiffUtil.DiffResult { + val callback = TransactionDiffCallback(oldList, newList) + return DiffUtil.calculateDiff(callback, detectMoves) + } + + /** + * Check if two transaction lists are effectively the same. + * This is a quick check based on size and hash values. + */ + fun areListsEqual( + oldList: List, + newList: List, + ): Boolean { + if (oldList.size != newList.size) return false + + // Quick hash-based comparison + val oldHashes = oldList.map { it.hash }.toSet() + val newHashes = newList.map { it.hash }.toSet() + + return oldHashes == newHashes + } + + /** + * Get new transactions (transactions in newList but not in oldList). + */ + fun getNewTransactions( + oldList: List, + newList: List, + ): List { + val oldHashes = oldList.map { it.hash }.toSet() + return newList.filter { it.hash !in oldHashes } + } + + /** + * Get removed transactions (transactions in oldList but not in newList). + */ + fun getRemovedTransactions( + oldList: List, + newList: List, + ): List { + val newHashes = newList.map { it.hash }.toSet() + return oldList.filter { it.hash !in newHashes } + } + + /** + * Merge two transaction lists, preferring newer data. + * Deduplicates by hash and sorts by timestamp descending. + */ + fun mergeLists( + oldList: List, + newList: List, + maxSize: Int = 100, + ): List { + val transactionMap = mutableMapOf() + + // Add old transactions first + oldList.forEach { tx -> + transactionMap[tx.hash] = tx + } + + // Add/update with new transactions (overwrites existing) + newList.forEach { tx -> + transactionMap[tx.hash] = tx + } + + // Sort by timestamp descending and limit + return transactionMap.values + .sortedByDescending { it.timestamp } + .take(maxSize) + } +} diff --git a/apps/androidkit/AndroidDemo/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/androidkit/AndroidDemo/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..9fe5d8494 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/androidkit/AndroidDemo/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/androidkit/AndroidDemo/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..9fe5d8494 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/androidkit/AndroidDemo/app/src/main/res/values/strings.xml b/apps/androidkit/AndroidDemo/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..a9b03be0d --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + WalletKit Demo + Performance Benchmark + diff --git a/apps/androidkit/AndroidDemo/app/src/main/res/values/themes.xml b/apps/androidkit/AndroidDemo/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..b221cddd5 --- /dev/null +++ b/apps/androidkit/AndroidDemo/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/apps/androidkit/AndroidDemo/build.gradle.kts b/apps/androidkit/AndroidDemo/build.gradle.kts new file mode 100644 index 000000000..793123bb2 --- /dev/null +++ b/apps/androidkit/AndroidDemo/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.kotlinAndroid) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinCompose) apply false + alias(libs.plugins.spotless) +} + +spotless { + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt", "**/generated/**/*.kt") + ktlint().editorConfigOverride( + mapOf("ktlint_function_naming_ignore_when_annotated_with" to "Composable"), + ) + } + kotlinGradle { + target("**/*.gradle.kts") + targetExclude("**/build/**") + ktlint() + } +} diff --git a/apps/androidkit/AndroidDemo/gradle.properties b/apps/androidkit/AndroidDemo/gradle.properties new file mode 100644 index 000000000..1f1277f34 --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx2g -Dkotlin.daemon.jvm.options=-Xmx2g diff --git a/apps/androidkit/AndroidDemo/gradle/libs.versions.toml b/apps/androidkit/AndroidDemo/gradle/libs.versions.toml new file mode 100644 index 000000000..528e78719 --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradle/libs.versions.toml @@ -0,0 +1,63 @@ +[versions] +agp = "8.13.0" +kotlin = "2.2.20" +spotless = "8.0.0" +androidxCoreKtx = "1.17.0" +androidxAppcompat = "1.7.1" +material = "1.13.0" +androidxActivity = "1.11.0" +constraintLayout = "2.2.1" +composeBom = "2025.10.00" +lifecycle = "2.9.4" +coroutinesAndroid = "1.10.2" +kotlinxSerialization = "1.9.0" +webkit = "1.14.0" +datastorePreferences = "1.1.7" +securityCrypto = "1.1.0" +biometric = "1.2.0-alpha05" +okhttp = "5.2.1" +junit = "4.13.2" +androidxTestExt = "1.3.0" +androidxTestRunner = "1.7.0" +mockk = "1.14.6" +androidxTestCore = "1.7.0" +robolectric = "4.16" +coroutinesTest = "1.10.2" + +[libraries] +androidxCoreKtx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } +androidxAppcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" } +googleMaterial = { module = "com.google.android.material:material", version.ref = "material" } +androidxActivityKtx = { module = "androidx.activity:activity-ktx", version.ref = "androidxActivity" } +androidxConstraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" } +androidxActivityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } +androidxComposeBom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidxComposeUi = { module = "androidx.compose.ui:ui" } +androidxComposeMaterial3 = { module = "androidx.compose.material3:material3" } +androidxComposeMaterialIconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidxComposeUiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidxComposeUiTooling = { module = "androidx.compose.ui:ui-tooling" } +androidxLifecycleRuntimeKtx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidxLifecycleViewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +kotlinxCoroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesAndroid" } +kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +androidxWebkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +androidxDatastorePreferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidxSecurityCrypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +androidxBiometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +junit = { module = "junit:junit", version.ref = "junit" } +androidxTestExt = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" } +androidxTestRunner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +androidxTestCore = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +shadowsFramework = { module = "org.robolectric:shadows-framework", version.ref = "robolectric" } +kotlinxCoroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/META-INF/MANIFEST.MF b/apps/androidkit/AndroidDemo/gradle/wrapper/META-INF/MANIFEST.MF new file mode 100644 index 000000000..9db312838 --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradle/wrapper/META-INF/MANIFEST.MF @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +Implementation-Title: Gradle +Implementation-Version: 8.7 + diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-base-annotations.jar b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-base-annotations.jar new file mode 100644 index 000000000..fcb8eb4b0 Binary files /dev/null and b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-base-annotations.jar differ diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-cli.jar b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-cli.jar new file mode 100644 index 000000000..527929daa Binary files /dev/null and b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-cli.jar differ diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-files.jar b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-files.jar new file mode 100644 index 000000000..0ab4bd80d Binary files /dev/null and b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-files.jar differ diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-functional.jar b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-functional.jar new file mode 100644 index 000000000..921ca53c9 Binary files /dev/null and b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-functional.jar differ diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper-classpath.properties b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper-classpath.properties new file mode 100644 index 000000000..68be06016 --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper-classpath.properties @@ -0,0 +1,2 @@ +projects=gradle-base-annotations,gradle-cli,gradle-files,gradle-functional,gradle-wrapper-shared +runtime= diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper-shared.jar b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper-shared.jar new file mode 100644 index 000000000..651d485b9 Binary files /dev/null and b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper-shared.jar differ diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper.jar b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..c8ea8b24b Binary files /dev/null and b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper.properties b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..2733ed5dc --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/androidkit/AndroidDemo/gradlew b/apps/androidkit/AndroidDemo/gradlew new file mode 100755 index 000000000..e8a7f0f30 --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradlew @@ -0,0 +1,18 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +APP_BASE_NAME=`basename "$0"` +APP_HOME=`dirname "$0"` + +DEFAULT_JVM_OPTS="-Xmx64m -Xms64m" + +GRADLE_WRAPPER_JAR="$APP_HOME/gradle/wrapper/gradle-wrapper.jar" +GRADLE_WRAPPER_SHARED_JAR="$APP_HOME/gradle/wrapper/gradle-wrapper-shared.jar" +CLASSPATH="$GRADLE_WRAPPER_JAR:$GRADLE_WRAPPER_SHARED_JAR:$APP_HOME/gradle/wrapper/gradle-cli.jar:$APP_HOME/gradle/wrapper/gradle-files.jar:$APP_HOME/gradle/wrapper/gradle-functional.jar:$APP_HOME/gradle/wrapper/gradle-base-annotations.jar" + +exec java $DEFAULT_JVM_OPTS -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/apps/androidkit/AndroidDemo/gradlew.bat b/apps/androidkit/AndroidDemo/gradlew.bat new file mode 100644 index 000000000..fa00e4774 --- /dev/null +++ b/apps/androidkit/AndroidDemo/gradlew.bat @@ -0,0 +1,5 @@ +@ECHO OFF +set APP_HOME=%~dp0 +set DEFAULT_JVM_OPTS=-Xmx64m -Xms64m +set GRADLE_WRAPPER_JAR=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +java -jar "%GRADLE_WRAPPER_JAR%" %* diff --git a/apps/androidkit/AndroidDemo/settings.gradle b/apps/androidkit/AndroidDemo/settings.gradle new file mode 100644 index 000000000..952fd5acb --- /dev/null +++ b/apps/androidkit/AndroidDemo/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "AndroidWalletKitDemo" +include(":app") +include(":bridge") +include(":storage") diff --git a/apps/androidkit/AndroidDemo/settings.gradle.kts b/apps/androidkit/AndroidDemo/settings.gradle.kts new file mode 100644 index 000000000..1946f8f5e --- /dev/null +++ b/apps/androidkit/AndroidDemo/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "AndroidDemo" + +include(":app") + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + google() + mavenCentral() + } +} diff --git a/apps/androidkit/README.md b/apps/androidkit/README.md new file mode 100644 index 000000000..662a12c3e --- /dev/null +++ b/apps/androidkit/README.md @@ -0,0 +1,38 @@ +# Android WalletKit + +TON blockchain wallet SDK for Android. + +## Structure + +- `TONWalletKit-Android/` - SDK library +- `AndroidDemo/` - Demo app +- `js/` - TypeScript source for bridge + +## Quick Start + +```bash +# 1. Build JavaScript bundles +pnpm install +pnpm run build:all + +# 2. Build SDK and copy to demo +cd TONWalletKit-Android +./gradlew buildAndCopyWebviewToDemo + +# 3. Run demo +cd ../AndroidDemo +./gradlew installDebug +``` + +## Build Variants + +- **webview** (1.2MB): WebView only, no native libs +- **full** (4.3MB): WebView + QuickJS with native libs + +Use webview for production. + +## Development + +Use Android Studio run configurations: +- "Build WebView & Copy to Demo" - default workflow +- "Build Full & Copy to Demo" - test QuickJS variant diff --git a/apps/androidkit/TONWalletKit-Android/.gitignore b/apps/androidkit/TONWalletKit-Android/.gitignore new file mode 100644 index 000000000..13eff6a27 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/.gitignore @@ -0,0 +1,14 @@ +# Gradle and build outputs +.gradle/ +build/ +app/build/ +.cxx/ +.idea/ +local.properties +*.iml +.DS_Store +captures/ +.kotlin + +# QuickJS source (deprecated, not for prod - see README.md) +bridge/src/main/cpp/third_party/quickjs-ng/ diff --git a/apps/androidkit/TONWalletKit-Android/.gradle/config.properties b/apps/androidkit/TONWalletKit-Android/.gradle/config.properties new file mode 100644 index 000000000..b28c05d6b --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/.gradle/config.properties @@ -0,0 +1,2 @@ +#Fri Oct 10 01:17:00 PYT 2025 +java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home diff --git a/apps/androidkit/TONWalletKit-Android/README.md b/apps/androidkit/TONWalletKit-Android/README.md new file mode 100644 index 000000000..a1be99d3d --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/README.md @@ -0,0 +1,55 @@ +# TON WalletKit Android SDK + +Android library for TON blockchain wallet integration. + +## Variants + +- **webview**: 1.2MB, WebView only (recommended) +- **full**: 4.3MB, WebView + QuickJS (deprecated, 2x slower) + +## QuickJS Source (Reference Only) + +QuickJS source removed from repo. If needed locally: +```bash +cd bridge/src/main/cpp/third_party/ +git clone https://github.com/bellard/quickjs-ng.git +``` + +## Building + +```bash +./gradlew assembleWebviewRelease +./gradlew assembleFullRelease + +# Build and copy to demo app +./gradlew buildAndCopyWebviewToDemo +``` + +## Integration + +Copy AAR to your `app/libs/` and add: + +```kotlin +dependencies { + implementation(files("libs/bridge-release.aar")) + implementation("androidx.webkit:webkit:1.12.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + // For full variant only: + // implementation("com.squareup.okhttp3:okhttp:4.12.0") +} +``` + +## Usage + +```kotlin +val walletKit = WalletKitEngineFactory.create( + context = applicationContext, + kind = WalletKitEngineKind.WEBVIEW +) +``` + +## Structure + +- `src/main/` - Common code, WebView engine +- `src/full/` - QuickJS-specific code +- `src/main/cpp/` - Native libraries diff --git a/apps/androidkit/TONWalletKit-Android/bridge/build.gradle.kts b/apps/androidkit/TONWalletKit-Android/bridge/build.gradle.kts new file mode 100644 index 000000000..f12e39fd5 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/build.gradle.kts @@ -0,0 +1,276 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinSerialization) + jacoco // Enable JaCoCo for test coverage +} + +android { + namespace = "io.ton.walletkit.bridge" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + flavorDimensions += "engine" + productFlavors { + create("webview") { + dimension = "engine" + // WebView-only: lighter AAR, no native libs, no OkHttp + } + create("full") { + dimension = "engine" + // Both engines: includes QuickJS with native libs + OkHttp + + @Suppress("UnstableApiUsage") + externalNativeBuild { + cmake { + arguments += listOf("-DANDROID_STL=c++_shared") + } + } + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + } + } + } + + androidComponents { + onVariants { variant -> + // Exclude native libs from webview variant only + if (variant.flavorName == "webview") { + variant.packaging.jniLibs.excludes.add("**/*.so") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // Only build native libs for full variant + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } + + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + + // Fix for Robolectric + JaCoCo coverage bytecode conflict + // https://github.com/robolectric/robolectric/issues/6593 + all { + // Add JVM args to avoid bytecode verification errors with instrumented classes + it.jvmArgs( + "-XX:+IgnoreUnrecognizedVMOptions", + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + // Disable bytecode verification for coverage-instrumented code + "-noverify", + ) + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +// WalletKit Bundle Build & Copy Tasks +val walletKitDistDir: File = + rootProject.rootDir + .toPath() + .resolve("../../dist-android") + .normalize() + .toFile() +val walletKitQuickJsDistDir: File = + rootProject.rootDir + .toPath() + .resolve("../../dist-android-quickjs") + .normalize() + .toFile() +val walletKitAssetsDir: File = layout.projectDirectory.dir("src/main/assets/walletkit").asFile + +// Task to build both WebView and QuickJS bundles using pnpm +val buildWalletKitBundles = + tasks.register("buildWalletKitBundles") { + group = "walletkit" + description = "Build both WebView and QuickJS WalletKit bundles using pnpm" + workingDir = rootProject.rootDir.parentFile + commandLine("sh", "-c", "cd ${rootProject.rootDir.parentFile.absolutePath} && pnpm run build:all") + // Only fail if pnpm is not found and we're actually building (not testing) + isIgnoreExitValue = true + doLast { + val result = executionResult.get() + if (result.exitValue != 0) { + logger.warn("pnpm build failed or pnpm not found. Skipping bundle generation.") + } + } + } + +// Task to copy WebView bundle +val syncWalletKitWebViewAssets = + tasks.register("syncWalletKitWebViewAssets") { + group = "walletkit" + description = "Copy WalletKit WebView bundle into bridge module assets (packaged in AAR)." + dependsOn(buildWalletKitBundles) + from(walletKitDistDir) + into(walletKitAssetsDir) + includeEmptyDirs = false + doFirst { + if (!walletKitDistDir.exists()) { + logger.warn( + "WebView bundle not found at $walletKitDistDir. Skipping asset copy.", + ) + // Don't throw exception, just skip + throw StopActionException() + } + } + } + +// Task to copy QuickJS bundle +val syncWalletKitQuickJsAssets = + tasks.register("syncWalletKitQuickJsAssets") { + group = "walletkit" + description = "Copy WalletKit QuickJS bundle into bridge module assets (packaged in AAR)." + dependsOn(buildWalletKitBundles) + from(walletKitQuickJsDistDir) { + include("walletkit.quickjs.js") + rename("walletkit.quickjs.js", "index.js") + } + into(walletKitAssetsDir.resolve("quickjs")) + doFirst { + if (!walletKitQuickJsDistDir.exists()) { + logger.warn( + "QuickJS bundle not found at $walletKitQuickJsDistDir. Skipping asset copy.", + ) + // Don't throw exception, just skip + throw StopActionException() + } + } + } + +// Ensure bundles are built and copied before assembling the AAR (but not for tests) +tasks.matching { it.name.contains("assemble") && !it.name.contains("Test") }.configureEach { + dependsOn(syncWalletKitWebViewAssets, syncWalletKitQuickJsAssets) +} + +// Fix implicit dependency warnings by explicitly declaring dependencies on merge tasks +tasks.matching { it.name.contains("merge") && it.name.contains("Assets") }.configureEach { + dependsOn(syncWalletKitWebViewAssets, syncWalletKitQuickJsAssets) +} + +// JaCoCo configuration - exclude deprecated QuickJS module from coverage +tasks.withType { + reports { + xml.required.set(true) + html.required.set(true) + } + + classDirectories.setFrom( + files( + classDirectories.files.map { + fileTree(it) { + exclude( + "**/QuickJsWalletKitEngine.class", + "**/QuickJsWalletKitEngine\$*.class", + "**/BuildConfig.class", + // Synthetic/generated classes + "**/*\$\$*.class", + ) + } + }, + ), + ) +} + +// Create coverage report task +tasks.register("jacocoTestReport") { + dependsOn("testWebviewDebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/html")) + } + + sourceDirectories.setFrom(files("src/main/java")) + classDirectories.setFrom( + fileTree("build/tmp/kotlin-classes/webviewDebug") { + exclude( + "**/QuickJsWalletKitEngine.class", + "**/QuickJsWalletKitEngine\$*.class", + "**/BuildConfig.class", + "**/*\$\$*.class", + ) + }, + ) + executionData.setFrom("build/jacoco/testWebviewDebugUnitTest.exec") +} + +dependencies { + implementation(libs.androidxCoreKtx) + implementation(libs.androidxLifecycleRuntimeKtx) + implementation(libs.kotlinxCoroutinesAndroid) + implementation(libs.kotlinxSerializationJson) + implementation(libs.androidxWebkit) + + // OkHttp only for Full variant (includes QuickJS) + "fullImplementation"(libs.okhttp) + + // Storage classes are now included in this module (merged from storage module) + implementation(libs.androidxDatastorePreferences) + implementation(libs.androidxSecurityCrypto) + + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinxCoroutinesTest) + testImplementation(libs.androidxTestCore) + testImplementation(libs.robolectric) + testImplementation(libs.shadowsFramework) + androidTestImplementation(libs.androidxTestExt) + androidTestImplementation(libs.androidxTestRunner) + androidTestImplementation(libs.kotlinxCoroutinesTest) + testImplementation(kotlin("test")) +} + +// Disable all CMake tasks for webview variants (no native code needed) +// This prevents CMake configuration errors when QuickJS sources are missing +tasks.configureEach { + val taskNameLower = name.lowercase() + val isWebviewVariant = taskNameLower.contains("webview") + val isCMakeTask = + taskNameLower.contains("cmake") || + taskNameLower.contains("nativebuild") || + taskNameLower.contains("externalNative".lowercase()) + + if (isWebviewVariant && isCMakeTask) { + enabled = false + logger.info("Disabled CMake task for webview variant: $name") + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/consumer-rules.pro b/apps/androidkit/TONWalletKit-Android/bridge/consumer-rules.pro new file mode 100644 index 000000000..b024096bc --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/consumer-rules.pro @@ -0,0 +1,152 @@ +# ============================================================ +# TON WalletKit Android SDK - Consumer ProGuard Rules +# These rules are automatically applied to apps using this AAR +# ============================================================ + +# ------------------------------------------------------------ +# 1. WebView JavaScript Interface +# Keep all methods annotated with @JavascriptInterface +# CRITICAL: JavaScript calls these methods by name at runtime +# ------------------------------------------------------------ +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# Keep the JsBinding inner class used by WebView engine +-keepclassmembers class io.ton.walletkit.presentation.impl.WebViewWalletKitEngine$JsBinding { + @android.webkit.JavascriptInterface ; +} + +# ------------------------------------------------------------ +# 2. QuickJS Native Bridge (Full variant only) +# Keep QuickJsNativeHost for reflection-based instantiation by QuickJS +# ------------------------------------------------------------ +-keep class io.ton.walletkit.presentation.impl.QuickJsNativeHost { + (); + public ; +} + +# Keep QuickJS JNI native methods +-keepclasseswithmembernames class io.ton.walletkit.presentation.impl.quickjs.QuickJs { + native ; +} + +# ------------------------------------------------------------ +# 3. Public API - Keep all public interfaces and classes +# Partners need access to these at runtime +# ------------------------------------------------------------ +-keep public interface io.ton.walletkit.presentation.WalletKitEngine { *; } +-keep public class io.ton.walletkit.presentation.WalletKitEngineFactory { *; } +-keep public enum io.ton.walletkit.presentation.WalletKitEngineKind { *; } +-keep public class io.ton.walletkit.presentation.WalletKitBridgeException { *; } + +# Keep configuration classes +-keep public class io.ton.walletkit.presentation.config.** { *; } + +# Keep event classes and interfaces +-keep public class io.ton.walletkit.presentation.event.** { *; } +-keep public interface io.ton.walletkit.presentation.listener.** { *; } + +# Keep request classes (used in events) +-keep public class io.ton.walletkit.presentation.request.** { + public ; + public ; +} + +# Keep domain models (return types from API) +-keep public class io.ton.walletkit.domain.model.** { + public ; + public ; +} + +# ------------------------------------------------------------ +# 4. Kotlinx Serialization (ONLY for library's internal use) +# Keep ONLY what serialization needs at runtime +# Reference: https://github.com/Kotlin/kotlinx.serialization#android +# ------------------------------------------------------------ + +# Keep Serializer classes for library's internal serialized classes +-keepclassmembers class io.ton.walletkit.** { + *** Companion; +} + +-keepclasseswithmembers class io.ton.walletkit.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Preserve serializer names for library classes +-keep,includedescriptorclasses class io.ton.walletkit.**$$serializer { *; } + +# Keep kotlinx.serialization runtime (library uses it internally) +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} + +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep generic signatures for serialization (only for library classes) +-keepattributes Signature +-keepattributes InnerClasses + +# REMOVED: -keepattributes *Annotation* +# Reason: This prevents optimizations in consumer apps. We don't need +# to keep ALL annotations - only what's specifically required above. + +# ------------------------------------------------------------ +# 5. Reflection (used by WalletKitEngineFactory) +# Keep classes that are loaded via Class.forName() +# ------------------------------------------------------------ + +# Keep engine implementations for reflection-based factory +-keep class io.ton.walletkit.presentation.impl.WebViewWalletKitEngine { + (android.content.Context, java.lang.String); +} + +-keep class io.ton.walletkit.presentation.impl.QuickJsWalletKitEngine { + (android.content.Context, java.lang.String, okhttp3.OkHttpClient); +} + +# ------------------------------------------------------------ +# 6. Android Security & Crypto +# Keep encrypted shared preferences (library uses internally) +# ------------------------------------------------------------ +-keep class androidx.security.crypto.** { *; } +-keep class com.google.crypto.tink.** { *; } + +# ------------------------------------------------------------ +# 7. Coroutines (library uses internally) +# ------------------------------------------------------------ +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} + +# Coroutines volatile fields optimization +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} + +# Avoid aggressive optimization breaking coroutines +-dontwarn kotlinx.coroutines.** + +# ------------------------------------------------------------ +# 8. OkHttp (Full variant only - used internally) +# ------------------------------------------------------------ +-dontwarn okhttp3.** +-dontwarn okio.** +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase + +# OkHttp platform implementations +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** + +# ------------------------------------------------------------ +# 9. JSON parsing (org.json - used for bridge communication) +# ------------------------------------------------------------ +# org.json is part of Android SDK, no special rules needed + +# ------------------------------------------------------------ +# 10. AndroidX WebKit (used by WebView engine) +# ------------------------------------------------------------ +-dontwarn androidx.webkit.** diff --git a/apps/androidkit/TONWalletKit-Android/bridge/proguard-rules.pro b/apps/androidkit/TONWalletKit-Android/bridge/proguard-rules.pro new file mode 100644 index 000000000..1264b24f2 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/proguard-rules.pro @@ -0,0 +1,18 @@ +# ============================================================ +# TON WalletKit Android SDK - Library Development ProGuard Rules +# These rules are only used during library development/testing +# For consumer apps, see consumer-rules.pro +# ============================================================ + +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle.kts. + +# For more details, see: +# http://developer.android.com/guide/developing/tools/proguard.html + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/androidTest/java/io/ton/walletkit/bridge/WebViewEngineInstrumentedTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/androidTest/java/io/ton/walletkit/bridge/WebViewEngineInstrumentedTest.kt new file mode 100644 index 000000000..8ed398778 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/androidTest/java/io/ton/walletkit/bridge/WebViewEngineInstrumentedTest.kt @@ -0,0 +1,432 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import android.webkit.WebView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.ton.walletkit.presentation.WalletKitEngineKind +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.event.WalletKitEvent +import io.ton.walletkit.presentation.impl.WebViewWalletKitEngine +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicInteger + +/** + * Instrumented tests for WebViewWalletKitEngine that run on a real Android device or emulator. + * These tests verify the actual WebView integration and JavaScript bridge functionality. + * + * Note: WebView must be created on the main thread, so we create it within each test + * on the main dispatcher. + */ +@RunWith(AndroidJUnit4::class) +class WebViewEngineInstrumentedTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + // ========== Initialization Tests ========== + + @Test + fun engineCreatesSuccessfully() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + assertNotNull(engine) + assertEquals(WalletKitEngineKind.WEBVIEW, engine.kind) + } finally { + engine.destroy() + } + } + } + + @Test + fun engineInitializesWithDefaultConfig() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(10_000) { + engine.init() + } + // If init() completes without throwing, the test passes + } finally { + engine.destroy() + } + } + } + + @Test + fun engineInitializesWithCustomConfig() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + val config = WalletKitBridgeConfig( + network = "testnet", + enablePersistentStorage = false, + ) + + withTimeout(10_000) { + engine.init(config) + } + // If init() completes without throwing, the test passes + } finally { + engine.destroy() + } + } + } + + @Test + fun multipleInitCallsAreIdempotent() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(10_000) { + engine.init() + engine.init() // Second call should not fail + engine.init() // Third call should not fail + } + } finally { + engine.destroy() + } + } + } + + @Test + fun webViewIsProperlyConfigured() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(10_000) { + engine.init() + } + + // Access WebView on main thread (we're already on it) + val webViewField = engine.javaClass.getDeclaredField("webView") + webViewField.isAccessible = true + val webView = webViewField.get(engine) as WebView + + assertTrue("JavaScript should be enabled", webView.settings.javaScriptEnabled) + assertTrue("DOM storage should be enabled", webView.settings.domStorageEnabled) + } finally { + engine.destroy() + } + } + } + + // ========== Wallet Management Tests ========== + + @Test + fun getWalletsReturnsEmptyListInitially() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + engine.init() + + val wallets = engine.getWallets() + + assertNotNull("Wallets list should not be null", wallets) + assertTrue("Wallets list should be empty initially", wallets.isEmpty()) + } + } finally { + engine.destroy() + } + } + } + + @Test + fun addWalletFromMnemonicCanBeCalled() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + engine.init() + + // Generate a valid 24-word mnemonic (these are valid BIP39 words) + val mnemonic = listOf( + "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", "abandon", "art", + ) + + // Just test that the API can be called without throwing + // The actual wallet creation depends on the JavaScript bundle being loaded + try { + val account = engine.addWalletFromMnemonic( + words = mnemonic, + name = "Test Wallet", + version = "v4r2", + ) + assertNotNull("Account should not be null", account) + } catch (e: Exception) { + // Expected if bundle not fully loaded - just verify API is callable + assertTrue("Exception should have message", e.message != null) + } + } + } finally { + engine.destroy() + } + } + } + + @Test + fun getWalletsCanBeCalled() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + engine.init() + + // Just test that getWallets can be called + val wallets = engine.getWallets() + assertNotNull("Wallets list should not be null", wallets) + // List might be empty if no wallets added + } + } finally { + engine.destroy() + } + } + } + + // ========== Event Handler Tests ========== + + @Test + fun eventHandlerCanBeAdded() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + val eventCount = AtomicInteger(0) + + val handler = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + eventCount.incrementAndGet() + } + } + + engine.init() + val closeable = engine.addEventHandler(handler) + + assertNotNull("Closeable should not be null", closeable) + } + } finally { + engine.destroy() + } + } + } + + @Test + fun eventHandlerCanBeRemoved() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + val eventCount = AtomicInteger(0) + + val handler = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + eventCount.incrementAndGet() + } + } + + engine.init() + val closeable = engine.addEventHandler(handler) + + // Remove handler by closing + closeable.close() + + // After removal, events should not be received + // (We can't easily test this without triggering actual events) + } + } finally { + engine.destroy() + } + } + } + + // ========== Session Management Tests ========== + + @Test + fun listSessionsReturnsEmptyListInitially() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + engine.init() + + val sessions = engine.listSessions() + + assertNotNull("Sessions list should not be null", sessions) + assertTrue("Sessions list should be empty initially", sessions.isEmpty()) + } + } finally { + engine.destroy() + } + } + } + + @Test + fun disconnectSessionDoesNotFailWhenNoSessions() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + engine.init() + + // Should not throw even when there are no sessions + engine.disconnectSession() + } + } finally { + engine.destroy() + } + } + } + + // ========== Network Configuration Tests ========== + + @Test + fun networkConfigurationWithTestnet() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + val config = WalletKitBridgeConfig( + network = "testnet", + enablePersistentStorage = false, + ) + + engine.init(config) + + // Network configuration should be applied successfully + } + } finally { + engine.destroy() + } + } + } + + @Test + fun networkConfigurationWithMainnet() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + val config = WalletKitBridgeConfig( + network = "mainnet", + enablePersistentStorage = false, + ) + + engine.init(config) + + // Network configuration should be applied successfully + } + } finally { + engine.destroy() + } + } + } + + @Test + fun storageCanBeDisabled() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(15_000) { + val config = WalletKitBridgeConfig( + enablePersistentStorage = false, + ) + + engine.init(config) + + // Storage disabled configuration should work + } + } finally { + engine.destroy() + } + } + } + + // ========== Resource Cleanup Tests ========== + + @Test + fun engineCanBeDestroyed() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + withTimeout(10_000) { + engine.init() + engine.destroy() + } + // Should not throw exception + } + } + + @Test + fun engineCanBeDestroyedMultipleTimes() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + withTimeout(10_000) { + engine.destroy() + engine.destroy() // Should not throw + engine.destroy() // Should not throw + } + } + } + + // ========== WebView Lifecycle Tests ========== + + @Test + fun webViewHandlesMultipleInitCalls() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(20_000) { + engine.init() + engine.init() + engine.init() + + // Multiple init calls should be handled gracefully + val wallets = engine.getWallets() + assertNotNull("Should still be able to call methods after multiple inits", wallets) + } + } finally { + engine.destroy() + } + } + } + + @Test + fun webViewHandlesRapidMethodCalls() = runBlocking { + withContext(Dispatchers.Main) { + val engine = WebViewWalletKitEngine(context) + try { + withTimeout(20_000) { + engine.init() + + // Perform rapid consecutive operations + repeat(5) { i -> + val wallets = engine.getWallets() + val sessions = engine.listSessions() + assertNotNull("Operation $i should succeed - wallets", wallets) + assertNotNull("Operation $i should succeed - sessions", sessions) + } + } + } finally { + engine.destroy() + } + } + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/androidTest/java/io/ton/walletkit/bridge/WebViewJsBridgeInstrumentedTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/androidTest/java/io/ton/walletkit/bridge/WebViewJsBridgeInstrumentedTest.kt new file mode 100644 index 000000000..2dca5fa8a --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/androidTest/java/io/ton/walletkit/bridge/WebViewJsBridgeInstrumentedTest.kt @@ -0,0 +1,399 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Instrumented tests for WebView JavaScript bridge functionality. + * Tests the low-level WebView-Kotlin communication layer. + */ +@RunWith(AndroidJUnit4::class) +class WebViewJsBridgeInstrumentedTest { + + private lateinit var context: Context + private lateinit var webView: WebView + private val mainHandler = Handler(Looper.getMainLooper()) + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + + // Create WebView on main thread + val latch = CountDownLatch(1) + mainHandler.post { + webView = WebView(context) + webView.settings.javaScriptEnabled = true + latch.countDown() + } + assertTrue("WebView should be created", latch.await(5, TimeUnit.SECONDS)) + } + + @After + fun tearDown() { + mainHandler.post { + webView.destroy() + } + } + + // ========== Basic JavaScript Execution ========== + + @Test + fun webViewCanExecuteBasicJavaScript() { + val resultRef = AtomicReference() + val latch = CountDownLatch(1) + + mainHandler.post { + webView.evaluateJavascript("2 + 2") { result -> + resultRef.set(result) + latch.countDown() + } + } + + assertTrue("Should receive JavaScript result", latch.await(5, TimeUnit.SECONDS)) + assertEquals("Should calculate correctly", "4", resultRef.get()) + } + + @Test + fun webViewCanExecuteJSONStringify() { + val resultRef = AtomicReference() + val latch = CountDownLatch(1) + + mainHandler.post { + webView.evaluateJavascript( + """ + JSON.stringify({ key: 'value', number: 42 }) + """.trimIndent(), + ) { result -> + resultRef.set(result) + latch.countDown() + } + } + + assertTrue("Should receive JavaScript result", latch.await(5, TimeUnit.SECONDS)) + + val jsonString = resultRef.get()?.trim('"')?.replace("\\", "") + assertNotNull("JSON result should not be null", jsonString) + + val json = JSONObject(jsonString!!) + assertEquals("Should have correct key", "value", json.getString("key")) + assertEquals("Should have correct number", 42, json.getInt("number")) + } + + // ========== JavaScript Interface Bridge ========== + + @Test + fun javascriptInterfaceCanReceiveMessages() { + val receivedMessage = AtomicReference() + val latch = CountDownLatch(1) + + val bridge = object { + @JavascriptInterface + fun postMessage(message: String) { + receivedMessage.set(message) + latch.countDown() + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive message from JavaScript", latch.await(10, TimeUnit.SECONDS)) + assertEquals("Should receive correct message", "Hello from JavaScript", receivedMessage.get()) + } + + @Test + fun javascriptInterfaceCanReceiveJSONMessages() { + val receivedJson = AtomicReference() + val latch = CountDownLatch(1) + + val bridge = object { + @JavascriptInterface + fun postMessage(jsonString: String) { + try { + receivedJson.set(JSONObject(jsonString)) + latch.countDown() + } catch (e: Exception) { + // Ignore parse errors + } + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive JSON message", latch.await(10, TimeUnit.SECONDS)) + + val json = receivedJson.get() + assertNotNull("JSON should be parsed", json) + assertEquals("Should have correct type", "test", json!!.getString("type")) + assertEquals("Should have correct value", 42, json.getInt("value")) + assertTrue("Should have correct flag", json.getBoolean("flag")) + } + + @Test + fun javascriptInterfaceCanHandleMultipleMessages() { + val messageCount = AtomicReference(0) + val latch = CountDownLatch(3) + + val bridge = object { + @JavascriptInterface + fun postMessage(message: String) { + messageCount.set(messageCount.get() + 1) + latch.countDown() + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive all messages", latch.await(10, TimeUnit.SECONDS)) + assertEquals("Should receive 3 messages", 3, messageCount.get()) + } + + // ========== Error Handling ========== + + @Test + fun javascriptInterfaceHandlesInvalidJSON() { + val receivedMessage = AtomicReference() + val latch = CountDownLatch(1) + + val bridge = object { + @JavascriptInterface + fun postMessage(message: String) { + receivedMessage.set(message) + latch.countDown() + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive message even if invalid JSON", latch.await(10, TimeUnit.SECONDS)) + assertNotNull("Message should be received", receivedMessage.get()) + assertFalse("Message should not be empty", receivedMessage.get()!!.isEmpty()) + } + + @Test + fun javascriptInterfaceHandlesEmptyString() { + val receivedMessage = AtomicReference() + val latch = CountDownLatch(1) + + val bridge = object { + @JavascriptInterface + fun postMessage(message: String) { + receivedMessage.set(message) + latch.countDown() + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive empty message", latch.await(10, TimeUnit.SECONDS)) + assertEquals("Message should be empty string", "", receivedMessage.get()) + } + + // ========== Complex Data Types ========== + + @Test + fun javascriptInterfaceHandlesNestedJSON() { + val receivedJson = AtomicReference() + val latch = CountDownLatch(1) + + val bridge = object { + @JavascriptInterface + fun postMessage(jsonString: String) { + try { + receivedJson.set(JSONObject(jsonString)) + latch.countDown() + } catch (e: Exception) { + // Ignore + } + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive nested JSON", latch.await(10, TimeUnit.SECONDS)) + + val json = receivedJson.get() + assertNotNull("JSON should be parsed", json) + + val outer = json!!.getJSONObject("outer") + val inner = outer.getJSONObject("inner") + assertEquals("Should have deeply nested value", "deeply nested", inner.getString("value")) + + val array = json.getJSONArray("array") + assertEquals("Array should have 3 elements", 3, array.length()) + assertEquals("Array should have correct values", 1, array.getInt(0)) + } + + @Test + fun javascriptInterfaceHandlesUnicodeCharacters() { + val receivedMessage = AtomicReference() + val latch = CountDownLatch(1) + + val bridge = object { + @JavascriptInterface + fun postMessage(message: String) { + receivedMessage.set(message) + latch.countDown() + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should receive unicode message", latch.await(10, TimeUnit.SECONDS)) + assertEquals("Should handle unicode correctly", "Hello 世界 🌍 Привет", receivedMessage.get()) + } + + // ========== Performance Tests ========== + + @Test + fun javascriptInterfaceHandlesRapidMessages() { + val messageCount = AtomicReference(0) + val latch = CountDownLatch(100) + + val bridge = object { + @JavascriptInterface + fun postMessage(message: String) { + messageCount.set(messageCount.get() + 1) + latch.countDown() + } + } + + mainHandler.post { + webView.addJavascriptInterface(bridge, "TestBridge") + webView.loadData( + """ + + + + """.trimIndent(), + "text/html", + "UTF-8", + ) + } + + assertTrue("Should handle 100 rapid messages", latch.await(15, TimeUnit.SECONDS)) + assertEquals("Should receive all 100 messages", 100, messageCount.get()) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/QuickJsWalletKitEngine.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/QuickJsWalletKitEngine.kt new file mode 100644 index 000000000..6bdd14374 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/QuickJsWalletKitEngine.kt @@ -0,0 +1,1981 @@ +package io.ton.walletkit.presentation.impl + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.core.content.edit +import io.ton.walletkit.domain.model.DAppInfo +import io.ton.walletkit.domain.model.SignDataResult +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.TransactionType +import io.ton.walletkit.domain.model.WalletAccount +import io.ton.walletkit.domain.model.WalletSession +import io.ton.walletkit.domain.model.WalletState +import io.ton.walletkit.presentation.WalletKitBridgeException +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.WalletKitEngineKind +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.event.WalletKitEvent +import io.ton.walletkit.presentation.impl.quickjs.QuickJs +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import io.ton.walletkit.presentation.request.ConnectRequest +import io.ton.walletkit.presentation.request.SignDataRequest +import io.ton.walletkit.presentation.request.TransactionRequest +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.Buffer +import okio.BufferedSource +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.Closeable +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.jvm.java + +/** + * QuickJS-backed implementation of [WalletKitEngine]. Executes the WalletKit JavaScript bundle + * inside the embedded QuickJS runtime and bridges JSON-RPC calls/events to Kotlin. + * + * @deprecated QuickJS engine is deprecated as of October 2025 due to poor performance (2x slower than WebView). + * Use [WebViewWalletKitEngine] instead. This implementation is preserved for reference and potential + * future optimization experiments only. See QUICKJS_DEPRECATION.md for details. + * + * Performance comparison (cold start): + * - QuickJS: 1881ms average (slower crypto operations) + * - WebView: 917ms average (2x faster) + * + * Migration: Replace `QuickJsWalletKitEngine(context)` with `WebViewWalletKitEngine(context)`. + */ +@Deprecated( + message = "QuickJS is 2x slower than WebView and is no longer maintained. Use WebViewWalletKitEngine instead.", + replaceWith = ReplaceWith( + "WebViewWalletKitEngine(context, assetPath, httpClient)", + "io.ton.walletkit.bridge.impl.WebViewWalletKitEngine", + ), + level = DeprecationLevel.WARNING, +) +/** + * @suppress Internal implementation class. Not part of public API. Use WalletKitEngineFactory.create instead. + */ +internal class QuickJsWalletKitEngine( + context: Context, + private val assetPath: String = DEFAULT_BUNDLE_ASSET, + private val httpClient: OkHttpClient = defaultHttpClient(), +) : WalletKitEngine { + override val kind: WalletKitEngineKind = WalletKitEngineKind.QUICKJS + + internal val logTag = "QuickJsWalletKitEngine" + private val appContext = context.applicationContext + internal val applicationContext: Context get() = appContext + private val assetManager = appContext.assets + private val eventHandlers = CopyOnWriteArraySet() + private val ready = CompletableDeferred() + private val pending = ConcurrentHashMap>() + internal val timerIdGenerator = AtomicInteger(1) + private val timers = ConcurrentHashMap() + internal val fetchIdGenerator = AtomicInteger(1) + internal val activeFetchCalls = ConcurrentHashMap() + internal val eventSourceIdGenerator = AtomicInteger(1) + private val eventSources = ConcurrentHashMap() + private val scriptCache = ConcurrentHashMap() + + private val jsExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "WalletKitQuickJs").apply { isDaemon = true } + } + private val jsDispatcher = jsExecutor.asCoroutineDispatcher() + private val jsScope = CoroutineScope(jsDispatcher + SupervisorJob()) + private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val mainScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private val timerExecutor = ScheduledThreadPoolExecutor(1) { runnable -> + Thread(runnable, "WalletKitQuickJsTimer").apply { isDaemon = true } + }.apply { + removeOnCancelPolicy = true + } + private val quickJsDeferred = CompletableDeferred() + + @Volatile private var quickJsInstance: QuickJs? = null + private val random = SecureRandom() + private val jsEvaluateMutex = Mutex() + + // Native helper instances + internal val nativeFetch = NativeFetch() + internal val nativeEventSource = NativeEventSource() + internal val nativeTimers = NativeTimers() + + @Volatile private var currentNetwork: String = "testnet" + + @Volatile private var apiBaseUrl: String = "https://testnet.tonapi.io" + + @Volatile private var tonApiKey: String? = null + + // Auto-initialization state + @Volatile private var isWalletKitInitialized = false + private val walletKitInitMutex = Mutex() + private var pendingInitConfig: WalletKitBridgeConfig? = null + + init { + jsScope.launch { + try { + Log.d(logTag, "Creating QuickJS instance...") + val quickJs = QuickJs.create() + Log.d(logTag, "Installing native bindings...") + installNativeBindings(quickJs) + Log.d(logTag, "Evaluating bootstrap...") + evaluateBootstrap(quickJs) + Log.d(logTag, "Installing text encoding shim...") + installTextEncodingShim(quickJs) + Log.d(logTag, "Loading bundle...") + loadBundle(quickJs) + Log.d(logTag, "Bundle loaded successfully") + quickJsInstance = quickJs + quickJsDeferred.complete(quickJs) + Log.d(logTag, "QuickJS initialization complete, waiting for ready event...") + } catch (err: Throwable) { + Log.e(logTag, "Failed to initialise QuickJS runtime", err) + quickJsDeferred.completeExceptionally(err) + if (!ready.isCompleted) { + ready.completeExceptionally( + WalletKitBridgeException( + err.message ?: "Failed to initialise QuickJS runtime", + ), + ) + } + } + } + } + + override fun addEventHandler(handler: WalletKitEventHandler): Closeable { + eventHandlers.add(handler) + return Closeable { eventHandlers.remove(handler) } + } + + /** + * Ensures WalletKit is initialized. If not already initialized, performs initialization + * with the provided config or defaults. This is called automatically by all public methods + * that require initialization, enabling auto-init behavior. + * + * @param config Configuration to use for initialization if not already initialized + */ + private suspend fun ensureWalletKitInitialized(config: WalletKitBridgeConfig = WalletKitBridgeConfig()) { + // Fast path: already initialized + if (isWalletKitInitialized) { + return + } + + walletKitInitMutex.withLock { + // Double-check after acquiring lock + if (isWalletKitInitialized) { + return@withLock + } + + Log.d(logTag, "Auto-initializing WalletKit with config: network=${config.network}") + + // Use pending config if init was called explicitly, otherwise use provided config + val effectiveConfig = pendingInitConfig ?: config + pendingInitConfig = null + + try { + performInitialization(effectiveConfig) + isWalletKitInitialized = true + Log.d(logTag, "WalletKit auto-initialization completed successfully") + } catch (err: Throwable) { + Log.e(logTag, "WalletKit auto-initialization failed", err) + throw WalletKitBridgeException( + "Failed to auto-initialize WalletKit: ${err.message}", + ) + } + } + } + + /** + * Performs the actual initialization by calling the JavaScript init method. + */ + private suspend fun performInitialization(config: WalletKitBridgeConfig) { + currentNetwork = config.network + val tonClientEndpoint = + config.tonClientEndpoint?.ifBlank { null } + ?: config.apiUrl?.ifBlank { null } + ?: defaultTonClientEndpoint(config.network) + apiBaseUrl = config.tonApiUrl?.ifBlank { null } ?: defaultTonApiBase(config.network) + tonApiKey = config.apiKey + + val payload = + JSONObject().apply { + put("network", config.network) + put("apiUrl", tonClientEndpoint) + config.apiUrl?.let { put("apiBaseUrl", it) } + config.tonApiUrl?.let { put("tonApiUrl", it) } + config.bridgeUrl?.let { put("bridgeUrl", it) } + config.bridgeName?.let { put("bridgeName", it) } + // Note: QuickJS engine doesn't support persistent storage yet + // Storage parameter removed from config + tonApiKey?.let { put("apiKey", it) } + } + + call("init", payload) + } + + override suspend fun init(config: WalletKitBridgeConfig) { + // Store config for use during auto-init if this is called before any other method + walletKitInitMutex.withLock { + if (!isWalletKitInitialized) { + pendingInitConfig = config + } + } + + // Ensure initialization happens with this config + ensureWalletKitInitialized(config) + } + + override suspend fun addWalletFromMnemonic( + words: List, + name: String?, + version: String, + network: String?, + ): WalletAccount { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put("words", JSONArray(words)) + put("version", version) + network?.let { put("network", it) } + name?.let { put("name", it) } + } + val result = call("addWalletFromMnemonic", params) + + // Parse the result into WalletAccount + return WalletAccount( + address = result.optString("address"), + publicKey = result.optNullableString("publicKey"), + name = result.optNullableString("name") ?: name, + version = result.optString("version", version), + network = result.optString("network", network ?: currentNetwork), + index = result.optInt("index", 0), + ) + } + + override suspend fun getWallets(): List { + ensureWalletKitInitialized() + Log.d(logTag, "getWallets() called") + val result = call("getWallets") + Log.d(logTag, "getWallets result: $result") + val items = result.optJSONArray("items") ?: JSONArray() + Log.d(logTag, "getWallets items count: ${items.length()}") + return buildList(items.length()) { + for (index in 0 until items.length()) { + val entry = items.optJSONObject(index) ?: continue + val account = WalletAccount( + address = entry.optString("address"), + publicKey = entry.optNullableString("publicKey"), + version = entry.optString("version", "unknown"), + network = entry.optString("network", currentNetwork), + index = entry.optInt("index", index), + ) + Log.d(logTag, "getWallets account: ${account.address}") + add(account) + } + } + } + + override suspend fun removeWallet(address: String) { + ensureWalletKitInitialized() + Log.d(logTag, "removeWallet called for address: $address") + val params = JSONObject().apply { put("address", address) } + val result = call("removeWallet", params) + Log.d(logTag, "removeWallet result: $result") + + // Check if removal was successful + val removed = when { + result.has("removed") -> result.optBoolean("removed", false) + result.has("ok") -> result.optBoolean("ok", true) + result.has("value") -> result.optBoolean("value", true) + else -> true + } + + if (!removed) { + throw WalletKitBridgeException("Failed to remove wallet: $address") + } + } + + override suspend fun getWalletState(address: String): WalletState { + ensureWalletKitInitialized() + Log.d(logTag, "getWalletState called for address: $address") + val params = JSONObject().apply { put("address", address) } + Log.d(logTag, "getWalletState calling JavaScript...") + val result = call("getWalletState", params) + Log.d(logTag, "getWalletState result: $result") + val balance = when { + result.has("balance") -> result.optString("balance") + result.has("value") -> result.optString("value") + else -> null + } + Log.d(logTag, "getWalletState balance: $balance") + return WalletState( + balance = balance, + transactions = parseTransactions(result.optJSONArray("transactions")), + ) + } + + override suspend fun getRecentTransactions(address: String, limit: Int): List { + ensureWalletKitInitialized() + val params = JSONObject().apply { + put("address", address) + put("limit", limit) + } + val result = call("getRecentTransactions", params) + return parseTransactions(result.optJSONArray("items")) + } + + /** + * Parse JSONArray of transactions into typed Transaction list. + */ + private fun parseTransactions(jsonArray: JSONArray?): List { + if (jsonArray == null) return emptyList() + + return buildList(jsonArray.length()) { + for (i in 0 until jsonArray.length()) { + val txJson = jsonArray.optJSONObject(i) ?: continue + + // Get messages + val inMsg = txJson.optJSONObject("in_msg") + val outMsgs = txJson.optJSONArray("out_msgs") + + // Filter out jetton/token transactions + // Jetton transactions have op_code in their messages or have a message body/payload + val isJettonOrTokenTx = when { + // Check incoming message for jetton markers + inMsg != null -> { + val opCode = inMsg.optString("op_code")?.takeIf { it.isNotEmpty() } + val body = inMsg.optString("body")?.takeIf { it.isNotEmpty() } + val message = inMsg.optString("message")?.takeIf { it.isNotEmpty() } + // Has op_code or has complex body (not just a comment) + opCode != null || (body != null && body != "te6ccgEBAQEAAgAAAA==") || + (message != null && message.length > 200) + } + // Check outgoing messages for jetton markers + outMsgs != null && outMsgs.length() > 0 -> { + var hasJettonMarkers = false + for (j in 0 until outMsgs.length()) { + val msg = outMsgs.optJSONObject(j) ?: continue + val opCode = msg.optString("op_code")?.takeIf { it.isNotEmpty() } + val body = msg.optString("body")?.takeIf { it.isNotEmpty() } + val message = msg.optString("message")?.takeIf { it.isNotEmpty() } + if (opCode != null || (body != null && body != "te6ccgEBAQEAAgAAAA==") || + (message != null && message.length > 200) + ) { + hasJettonMarkers = true + break + } + } + hasJettonMarkers + } + else -> false + } + + // Skip non-TON transactions + if (isJettonOrTokenTx) { + Log.d(logTag, "Skipping jetton/token transaction: ${txJson.optString("hash", "unknown")}") + continue + } + + // Determine transaction type based on incoming/outgoing value + // Check if incoming message has value (meaning we received funds) + val incomingValue = inMsg?.optString("value")?.toLongOrNull() ?: 0L + val hasIncomingValue = incomingValue > 0 + + // Check if we have outgoing messages with value + var outgoingValue = 0L + if (outMsgs != null) { + for (j in 0 until outMsgs.length()) { + val msg = outMsgs.optJSONObject(j) + val value = msg?.optString("value")?.toLongOrNull() ?: 0L + outgoingValue += value + } + } + val hasOutgoingValue = outgoingValue > 0 + + // Transaction is INCOMING if we received value, OUTGOING if we only sent value + // Note: Many incoming transactions also have outgoing messages (fees, change, etc.) + val type = when { + hasIncomingValue -> TransactionType.INCOMING + hasOutgoingValue -> TransactionType.OUTGOING + else -> TransactionType.UNKNOWN + } + + add( + Transaction( + hash = txJson.optString("hash", ""), + // Convert to milliseconds + timestamp = txJson.optLong("utime", 0L) * 1000, + amount = when (type) { + TransactionType.INCOMING -> incomingValue.toString() + TransactionType.OUTGOING -> outgoingValue.toString() + else -> "0" + }, + fee = txJson.optString("fee"), + comment = when (type) { + TransactionType.INCOMING -> inMsg?.optString("message") + TransactionType.OUTGOING -> outMsgs?.optJSONObject(0)?.optString("message") + else -> null + }, + sender = inMsg?.optString("source"), + recipient = outMsgs?.optJSONObject(0)?.optString("destination"), + type = type, + ), + ) + } + } + } + + override suspend fun handleTonConnectUrl(url: String) { + ensureWalletKitInitialized() + val params = JSONObject().apply { put("url", url) } + call("handleTonConnectUrl", params) + } + + override suspend fun sendTransaction( + walletAddress: String, + recipient: String, + amount: String, + comment: String?, + ) { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put("walletAddress", walletAddress) + put("toAddress", recipient) + put("amount", amount) + if (!comment.isNullOrBlank()) { + put("comment", comment) + } + } + call("sendTransaction", params) + } + + override suspend fun approveConnect(event: io.ton.walletkit.presentation.event.ConnectRequestEvent) { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put("requestId", event.id) + put("walletAddress", event.walletAddress) + } + call("approveConnectRequest", params) + } + + override suspend fun rejectConnect( + event: io.ton.walletkit.presentation.event.ConnectRequestEvent, + reason: String?, + ) { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put("requestId", event.id) + reason?.let { put("reason", it) } + } + call("rejectConnectRequest", params) + } + + override suspend fun approveTransaction(event: io.ton.walletkit.presentation.event.TransactionRequestEvent) { + ensureWalletKitInitialized() + val params = JSONObject().apply { put("requestId", event.id) } + call("approveTransactionRequest", params) + } + + override suspend fun rejectTransaction( + event: io.ton.walletkit.presentation.event.TransactionRequestEvent, + reason: String?, + ) { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put("requestId", event.id) + reason?.let { put("reason", it) } + } + call("rejectTransactionRequest", params) + } + + override suspend fun approveSignData(event: io.ton.walletkit.presentation.event.SignDataRequestEvent): SignDataResult { + ensureWalletKitInitialized() + val params = JSONObject().apply { put("requestId", event.id) } + val result = call("approveSignDataRequest", params) + + Log.d(logTag, "approveSignData raw result: $result") + + // Extract signature from the response + // The result might be nested in a 'result' object or directly accessible + val signature = result.optNullableString("signature") + ?: result.optJSONObject("result")?.optNullableString("signature") + ?: result.optJSONObject("data")?.optNullableString("signature") + + if (signature.isNullOrEmpty()) { + throw WalletKitBridgeException("No signature in approveSignData response: $result") + } + + return SignDataResult(signature = signature) + } + + override suspend fun rejectSignData( + event: io.ton.walletkit.presentation.event.SignDataRequestEvent, + reason: String?, + ) { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put("requestId", event.id) + reason?.let { put("reason", it) } + } + call("rejectSignDataRequest", params) + } + + override suspend fun listSessions(): List { + ensureWalletKitInitialized() + val result = call("listSessions") + val items = result.optJSONArray("items") ?: JSONArray() + return buildList(items.length()) { + for (index in 0 until items.length()) { + val entry = items.optJSONObject(index) ?: continue + add( + WalletSession( + sessionId = entry.optString("sessionId"), + dAppName = entry.optString("dAppName"), + walletAddress = entry.optString("walletAddress"), + dAppUrl = entry.optNullableString("dAppUrl"), + manifestUrl = entry.optNullableString("manifestUrl"), + iconUrl = entry.optNullableString("iconUrl"), + createdAtIso = entry.optNullableString("createdAt"), + lastActivityIso = entry.optNullableString("lastActivity"), + ), + ) + } + } + } + + override suspend fun disconnectSession(sessionId: String?) { + ensureWalletKitInitialized() + val params = JSONObject() + sessionId?.let { params.put("sessionId", it) } + call("disconnectSession", if (params.length() == 0) null else params) + } + + override suspend fun injectSignDataRequest(requestData: JSONObject): JSONObject { + ensureWalletKitInitialized() + return call("injectSignDataRequest", requestData) + } + + override suspend fun destroy() { + withContext(jsDispatcher) { + quickJsInstance?.close() + quickJsInstance = null + if (!quickJsDeferred.isCompleted) { + quickJsDeferred.cancel() + } + } + timers.values.forEach { handle -> + handle.future?.cancel(true) + } + timers.clear() + timerExecutor.shutdownNow() + activeFetchCalls.values.forEach { call -> + call.cancel() + } + activeFetchCalls.clear() + ioScope.cancel() + mainScope.cancel() + jsScope.cancel() + jsExecutor.shutdownNow() + } + + private suspend fun call( + method: String, + params: JSONObject? = null, + ): JSONObject { + ready.await() + Log.d(logTag, "call: method=$method, params=$params") + val callId = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[callId] = deferred + val payload = params?.toString() + val idLiteral = JSONObject.quote(callId) + val methodLiteral = JSONObject.quote(method) + val script = + if (payload == null) { + "globalThis.__walletkitCall($idLiteral,$methodLiteral,null)" + } else { + val payloadLiteral = JSONObject.quote(payload) + "globalThis.__walletkitCall($idLiteral,$methodLiteral,$payloadLiteral)" + } + Log.d(logTag, "call: evaluating script...") + evaluate(script, "walletkit-call.js") + Log.d(logTag, "call: waiting for response...") + val response = deferred.await() + Log.d(logTag, "call: got response") + return response.result + } + + private suspend fun evaluate(script: String, filename: String = "walletkit-eval.js") { + val quickJs = quickJsDeferred.await() + withContext(jsDispatcher) { + jsEvaluateMutex.withLock { + quickJs.evaluate(script, filename) + drainPendingJobsLocked(quickJs) + } + } + } + + private fun drainPendingJobsLocked(runtime: QuickJs) { + while (true) { + val jobsProcessed = try { + runtime.executePendingJob() + } catch (err: Throwable) { + Log.e(logTag, "QuickJS pending job failed", err) + return + } + if (jobsProcessed <= 0) { + return + } + } + } + + private fun readJsAsset(relativePath: String): String = scriptCache.getOrPut(relativePath) { + assetManager.open(relativePath).bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } + + private fun failPendingRequests(message: String) { + pending.values.forEach { deferred -> + if (!deferred.isCompleted) { + deferred.completeExceptionally(WalletKitBridgeException(message)) + } + } + } + + private fun installNativeBindings(quickJs: QuickJs) { + Log.d(logTag, "Setting WalletKitNative (class, no @JvmStatic, let QuickJS instantiate)...") + // Set engine reference BEFORE registering with QuickJS + QuickJsNativeHost.engine = this + + // DEBUG: Try calling the method directly from Kotlin to verify it works + Log.d(logTag, "Direct Kotlin call test:") + val testInstance = QuickJsNativeHost() + val directResult = testInstance.cryptoRandomBytes("32") + Log.d(logTag, "Direct call returned: $directResult (${directResult.length} chars)") + + // Register class - let QuickJS create its own instance via reflection + quickJs.set("WalletKitNative", QuickJsNativeHost::class.java, testInstance) + + Log.d(logTag, "All native bindings installed via unified host") + } + + private fun evaluateBootstrap(quickJs: QuickJs) { + // Test the unified host + Log.d(logTag, "Testing unified WalletKitNative host...") + + // Test 1: Check if functions are different objects + val identityTest = quickJs.evaluate( + """ + (function() { + return 'base64Decode === cryptoRandomBytes: ' + (WalletKitNative.base64Decode === WalletKitNative.cryptoRandomBytes); + })(); + """.trimIndent(), + "test-identity.js", + ) + Log.d(logTag, "Identity test = $identityTest") + + // Test 2: Check object structure + val structureTest = quickJs.evaluate( + """ + (function() { + var keys = Object.keys(WalletKitNative).sort(); + return 'Methods: ' + keys.join(', '); + })(); + """.trimIndent(), + "test-structure.js", + ) + Log.d(logTag, "Structure test = $structureTest") + + // Test 3: Call base64Decode explicitly + val base64Test = quickJs.evaluate( + """WalletKitNative.base64Decode('aGVsbG8=')""", + "test-base64.js", + ) + Log.d(logTag, "base64Decode('aGVsbG8=') = '$base64Test'") + + // Test 4: Call base64Encode explicitly + val encodeTest = quickJs.evaluate( + """WalletKitNative.base64Encode('hello')""", + "test-encode.js", + ) + Log.d(logTag, "base64Encode('hello') = '$encodeTest'") + + // Test 5: Call cryptoRandomBytes explicitly + val cryptoTest = quickJs.evaluate( + """WalletKitNative.cryptoRandomBytes('32')""", + "test-crypto.js", + ) + Log.d(logTag, "cryptoRandomBytes('32') = '$cryptoTest' (len=${cryptoTest.toString().length})") + + quickJs.evaluate(readJsAsset(BOOTSTRAP_SCRIPT_ASSET), "walletkit-bootstrap.js") + drainPendingJobsLocked(quickJs) + + Log.d(logTag, "Bootstrap evaluation complete") + } + + private fun installTextEncodingShim(quickJs: QuickJs) { + quickJs.evaluate(readJsAsset(TEXT_ENCODING_ASSET), "walletkit-text-encoding.js") + drainPendingJobsLocked(quickJs) + } + + private fun loadBundle(quickJs: QuickJs) { + quickJs.evaluate(readJsAsset(ENVIRONMENT_SHIM_ASSET), "walletkit-environment.js") + val source = readJsAsset(assetPath) + quickJs.evaluate(source, assetPath) + // Drain pending jobs to execute any initialization code + Log.d(logTag, "Draining pending jobs after bundle load...") + drainPendingJobsLocked(quickJs) + Log.d(logTag, "Pending jobs drained") + } + + internal fun handleReady(payload: JSONObject) { + payload.optNullableString("network")?.let { currentNetwork = it } + payload.optNullableString("tonApiUrl")?.let { apiBaseUrl = it } + if (!ready.isCompleted) { + Log.d(logTag, "QuickJS bridge ready") + ready.complete(Unit) + } + val data = JSONObject() + val keys = payload.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key == "kind") continue + if (payload.isNull(key)) { + data.put(key, JSONObject.NULL) + } else { + data.put(key, payload.get(key)) + } + } + val readyEvent = JSONObject().apply { + put("type", "ready") + put("data", data) + } + handleEvent(readyEvent) + } + + internal fun handleResponse( + id: String, + payload: JSONObject, + ) { + val deferred = pending.remove(id) ?: return + val error = payload.optJSONObject("error") + if (error != null) { + val message = error.optString("message", "WalletKit bridge error") + Log.e(logTag, "handleResponse error: $error") + deferred.completeExceptionally(WalletKitBridgeException(message)) + return + } + val result = payload.opt("result") + val data = + when (result) { + is JSONObject -> result + is JSONArray -> JSONObject().put("items", result) + null -> JSONObject() + else -> JSONObject().put("value", result) + } + deferred.complete(BridgeResponse(data)) + } + + internal fun handleEvent(event: JSONObject) { + val type = event.optString("type", "unknown") + val data = event.optJSONObject("data") ?: JSONObject() + + // Typed event handlers (sealed class) + val typedEvent = parseTypedEvent(type, data, event) + if (typedEvent != null) { + eventHandlers.forEach { handler -> + mainScope.launch { handler.handleEvent(typedEvent) } + } + } + } + + private fun parseTypedEvent(type: String, data: JSONObject, raw: JSONObject): WalletKitEvent? { + return when (type) { + "connectRequest" -> { + val id = data.optString("id") ?: return null + val dAppInfo = parseDAppInfo(data) + val permissionsArray = data.optJSONArray("permissions") ?: JSONArray() + val permissions = buildList { + for (i in 0 until permissionsArray.length()) { + val permName = permissionsArray.optString(i) + if (!permName.isNullOrEmpty()) { + add( + io.ton.walletkit.presentation.event.ConnectRequestEvent.ConnectPermission( + name = permName, + title = permName, + description = "", + ), + ) + } + } + } + + val preview = io.ton.walletkit.presentation.event.ConnectRequestEvent.Preview( + manifest = dAppInfo?.let { + io.ton.walletkit.presentation.event.ConnectRequestEvent.Manifest( + name = it.name, + description = null, + url = it.url, + iconUrl = it.iconUrl, + ) + }, + permissions = permissions, + ) + + val event = io.ton.walletkit.presentation.event.ConnectRequestEvent( + id = id, + preview = preview, + dAppInfo = dAppInfo, + walletAddress = null, + ) + + val request = ConnectRequest( + requestId = id, + dAppInfo = dAppInfo, + permissions = permissions, + event = event, + engine = this, + ) + WalletKitEvent.ConnectRequestEvent(request) + } + + "transactionRequest" -> { + val id = data.optString("id") ?: return null + val dAppInfo = parseDAppInfo(data) + val txRequest = parseTransactionRequest(data) + + // Create minimal typed event for QuickJS (doesn't have full preview data) + val preview = io.ton.walletkit.presentation.event.TransactionRequestEvent.Preview( + manifest = dAppInfo?.let { + io.ton.walletkit.presentation.event.TransactionRequestEvent.Manifest( + name = it.name, + url = it.url, + iconUrl = it.iconUrl, + ) + }, + ) + + val event = io.ton.walletkit.presentation.event.TransactionRequestEvent( + id = id, + preview = preview, + ) + + val request = TransactionRequest( + requestId = id, + dAppInfo = dAppInfo, + request = txRequest, + event = event, + engine = this, + ) + WalletKitEvent.TransactionRequestEvent(request) + } + + "signDataRequest" -> { + val id = data.optString("id") ?: return null + val dAppInfo = parseDAppInfo(data) + val signRequest = parseSignDataRequest(data) + + // Create minimal typed event for QuickJS + val preview = io.ton.walletkit.presentation.event.SignDataRequestEvent.Preview( + kind = io.ton.walletkit.presentation.event.SignDataType.TEXT, + content = signRequest.payload, + schema = null, + ) + + val event = io.ton.walletkit.presentation.event.SignDataRequestEvent( + id = id, + preview = preview, + request = io.ton.walletkit.presentation.event.SignDataRequestEvent.Payload( + type = io.ton.walletkit.presentation.event.SignDataType.TEXT, + text = signRequest.payload, + ), + dAppInfo = dAppInfo, + ) + + val request = SignDataRequest( + requestId = id, + dAppInfo = dAppInfo, + request = signRequest, + event = event, + engine = this, + ) + WalletKitEvent.SignDataRequestEvent(request) + } + + "disconnect" -> { + val sessionId = data.optNullableString("sessionId") + ?: data.optNullableString("id") + ?: return null + WalletKitEvent.DisconnectEvent(sessionId) + } + + "stateChanged", "walletStateChanged" -> { + val address = data.optNullableString("address") + ?: data.optJSONObject("wallet")?.optNullableString("address") + ?: return null + WalletKitEvent.StateChangedEvent(address) + } + + "sessionsChanged" -> { + WalletKitEvent.SessionsChangedEvent + } + + else -> null // Unknown event type + } + } + + private fun parseDAppInfo(data: JSONObject): DAppInfo? { + // Try to get dApp name from multiple sources + val dAppName = data.optNullableString("dAppName") + ?: data.optJSONObject("manifest")?.optNullableString("name") + ?: data.optJSONObject("preview")?.optJSONObject("manifest")?.optNullableString("name") + + // Try to get URLs from multiple sources + val manifest = data.optJSONObject("preview")?.optJSONObject("manifest") + ?: data.optJSONObject("manifest") + + val dAppUrl = data.optNullableString("dAppUrl") + ?: manifest?.optNullableString("url") + ?: "" + + val iconUrl = data.optNullableString("dAppIconUrl") + ?: manifest?.optNullableString("iconUrl") + + val manifestUrl = data.optNullableString("manifestUrl") + ?: manifest?.optNullableString("url") + + // Only return null if we have absolutely no dApp information + if (dAppName == null && dAppUrl.isEmpty()) { + return null + } + + return DAppInfo( + name = dAppName ?: dAppUrl.takeIf { it.isNotEmpty() } ?: "Unknown dApp", + url = dAppUrl, + iconUrl = iconUrl, + manifestUrl = manifestUrl, + ) + } + + private fun parsePermissions(data: JSONObject): List { + val permissions = data.optJSONArray("permissions") ?: return emptyList() + return List(permissions.length()) { i -> + permissions.optString(i) + } + } + + private fun parseTransactionRequest(data: JSONObject): io.ton.walletkit.domain.model.TransactionRequest = io.ton.walletkit.domain.model.TransactionRequest( + recipient = data.optNullableString("to") ?: data.optNullableString("recipient") ?: "", + amount = data.optNullableString("amount") ?: data.optNullableString("value") ?: "0", + comment = data.optNullableString("comment") ?: data.optNullableString("text"), + payload = data.optNullableString("payload"), + ) + + private fun parseSignDataRequest(data: JSONObject): io.ton.walletkit.domain.model.SignDataRequest { + // Parse params array - params[0] contains stringified JSON with schema_crc and payload + var payload = data.optNullableString("payload") ?: data.optNullableString("data") ?: "" + var schema: String? = data.optNullableString("schema") + + // Check if params array exists (newer format from bridge) + val paramsArray = data.optJSONArray("params") + if (paramsArray != null && paramsArray.length() > 0) { + val paramsString = paramsArray.optString(0) + if (paramsString.isNotEmpty()) { + try { + val paramsObj = JSONObject(paramsString) + payload = paramsObj.optNullableString("payload") ?: payload + + // Convert schema_crc to human-readable schema type + val schemaCrc = paramsObj.optInt("schema_crc", -1) + schema = when (schemaCrc) { + 0 -> "text" + 1 -> "binary" + 2 -> "cell" + else -> schema + } + } catch (e: Exception) { + Log.e(logTag, "Failed to parse params for sign data", e) + } + } + } + + return io.ton.walletkit.domain.model.SignDataRequest( + payload = payload, + schema = schema, + ) + } + + private fun JSONObject.optNullableString(key: String): String? { + val value = opt(key) + return when (value) { + null, JSONObject.NULL -> null + else -> value.toString() + } + } + + private fun defaultTonClientEndpoint(network: String): String = if (network.equals("mainnet", ignoreCase = true)) { + "https://toncenter.com/api/v2/jsonRPC" + } else { + "https://testnet.toncenter.com/api/v2/jsonRPC" + } + + private fun defaultTonApiBase(network: String): String = if (network.equals("mainnet", ignoreCase = true)) { + "https://tonapi.io" + } else { + "https://testnet.tonapi.io" + } + + private inner class NativeBridge { + fun postMessage(json: Any?) { + Log.v(logTag, ">>> NativeBridge.postMessage CALLED with type: ${json?.javaClass?.simpleName}") + try { + Log.d(logTag, "NativeBridge.postMessage called with type: ${json?.javaClass?.simpleName}, value: ${if (json is String) json.take(200) else json}") + + // Convert to string if needed (QuickJS might pass different types) + val jsonString = when (json) { + is String -> json + is Number -> { + Log.w(logTag, "postMessage received number: $json (likely a char code or unexpected value) - ignoring") + return // Ignore numeric values + } + null -> { + Log.w(logTag, "postMessage received null - ignoring") + return + } + else -> { + Log.w(logTag, "postMessage received unexpected type: ${json::class.java.simpleName}, value: $json - converting to string") + json.toString() + } + } + + val payload = JSONObject(jsonString) + val kind = payload.optString("kind") + Log.d(logTag, "postMessage payload kind: $kind") + + when (kind) { + "ready" -> handleReady(payload) + "event" -> payload.optJSONObject("event")?.let { handleEvent(it) } + "response" -> handleResponse(payload.optString("id"), payload) + } + } catch (err: JSONException) { + Log.e(logTag, "Malformed payload from QuickJS: $json", err) + // Don't fail pending requests for malformed payloads during initialization + } catch (err: Throwable) { + Log.e(logTag, "NativeBridge.postMessage error", err) + // Don't rethrow - this would break QuickJS initialization + } + } + } + + private inner class NativeConsole { + fun log(level: String, message: String): String { + Log.v(logTag, ">>> NativeConsole.log CALLED: level=$level, message=$message") + try { + when (level.lowercase()) { + "error" -> Log.e(logTag, message) + "warn" -> Log.w(logTag, message) + else -> Log.d(logTag, message) + } + } catch (err: Throwable) { + Log.e(logTag, "NativeConsole.log error", err) + } + return "logged-ok" + } + } + + private inner class NativeBase64 { + fun encode(value: String): String = try { + val bytes = value.toByteArray(Charsets.ISO_8859_1) + Base64.encodeToString(bytes, Base64.NO_WRAP) + } catch (err: Throwable) { + Log.w(logTag, "NativeBase64.encode failed for input: '${value.take(100)}': ${err.message}") + "" // Return empty string on error + } + + fun decode(value: String): String { + return try { + // Handle empty or whitespace-only strings + if (value.isBlank()) { + return "" + } + val bytes = Base64.decode(value, Base64.NO_WRAP) + String(bytes, Charsets.ISO_8859_1) + } catch (err: Throwable) { + // Log as warning, not error - this can happen with invalid input and is handled gracefully + Log.w(logTag, "NativeBase64.decode failed for input: '${value.take(100)}' (length: ${value.length}): ${err.message}") + "" // Return empty string on error + } + } + } + + private inner class NativeCrypto { + fun randomBytes(length: Int): String = try { + val buffer = ByteArray(length.coerceAtLeast(0)) + random.nextBytes(buffer) + Base64.encodeToString(buffer, Base64.NO_WRAP) + } catch (err: Throwable) { + Log.e(logTag, "NativeCrypto.randomBytes error", err) + "" + } + + fun randomUuid(): String = try { + UUID.randomUUID().toString() + } catch (err: Throwable) { + Log.e(logTag, "NativeCrypto.randomUuid error", err) + "" + } + + fun pbkdf2Sha512(keyBase64: String, saltBase64: String, iterations: Int, keyLen: Int): String { + Log.d(logTag, "NativeCrypto.pbkdf2Sha512 called: iterations=$iterations, keyLen=$keyLen") + return try { + val key = Base64.decode(keyBase64, Base64.NO_WRAP) + val salt = Base64.decode(saltBase64, Base64.NO_WRAP) + + val spec = PBEKeySpec( + String(key, Charsets.UTF_8).toCharArray(), + salt, + iterations, + keyLen * 8, + ) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") + val derived = factory.generateSecret(spec).encoded + + val result = Base64.encodeToString(derived, Base64.NO_WRAP) + Log.d(logTag, "NativeCrypto.pbkdf2Sha512 success, result length=${result.length}") + result + } catch (err: Throwable) { + Log.e(logTag, "NativeCrypto.pbkdf2Sha512 error", err) + "" + } + } + } + + internal inner class NativeTimers { + fun request(delayMs: Double, repeat: Boolean): Int = try { + val id = timerIdGenerator.getAndIncrement() + val delay = + when { + delayMs.isNaN() || delayMs.isInfinite() -> 0L + delayMs < 0 -> 0L + else -> delayMs.toLong() + } + createTimer(id, delay, repeat) + id + } catch (err: Throwable) { + Log.e(logTag, "NativeTimers.request error", err) + -1 + } + + fun clear(id: Int) { + try { + cancelTimer(id) + } catch (err: Throwable) { + Log.e(logTag, "NativeTimers.clear error", err) + } + } + } + + internal inner class NativeFetch { + fun perform(requestJson: String): Int { + val requestId = fetchIdGenerator.getAndIncrement() + try { + val json = JSONObject(requestJson) + val url = json.optString("url") + val method = json.optString("method", "GET").ifBlank { "GET" } + Log.d(logTag, "NativeFetch.perform: id=$requestId, method=$method, url=${url.take(100)}") + if (url.isNullOrBlank()) { + deliverFetchError(requestId, "Missing URL") + return requestId + } + val headers = json.optJSONArray("headers") + val headersBuilder = Headers.Builder() + if (headers != null) { + for (index in 0 until headers.length()) { + val pair = headers.optJSONArray(index) ?: continue + val name = pair.optString(0) + val value = pair.optString(1) + if (name.isNotBlank()) { + headersBuilder.add(name, value) + } + } + } + val headersBuilt = headersBuilder.build() + val bodyBase64 = json.optString("bodyBase64", "").takeIf { it.isNotEmpty() } + val requestBody = createRequestBody(method.uppercase(), headersBuilt, bodyBase64) + val request = + Request.Builder() + .url(url) + .headers(headersBuilt) + .method(method.uppercase(), requestBody) + .build() + ioScope.launch { + executeFetch(requestId, request) + } + } catch (err: Throwable) { + deliverFetchError(requestId, err.message ?: "Fetch request failed") + } + return requestId + } + + fun abort(id: Int) { + try { + activeFetchCalls.remove(id)?.cancel() + deliverFetchError(id, "Aborted") + } catch (err: Throwable) { + Log.e(logTag, "NativeFetch.abort error", err) + } + } + } + + private fun createRequestBody( + method: String, + headers: Headers, + bodyBase64: String?, + ): RequestBody? { + val requiresBody = method !in listOf("GET", "HEAD") + if (bodyBase64 == null) { + return if (requiresBody) { + ByteArray(0).toRequestBody(null) + } else { + null + } + } + val bodyBytes = Base64.decode(bodyBase64, Base64.NO_WRAP) + val mediaType = headers["Content-Type"]?.toMediaTypeOrNull() + return bodyBytes.toRequestBody(mediaType) + } + + private suspend fun executeFetch( + id: Int, + request: Request, + ) { + val call = httpClient.newCall(request) + activeFetchCalls[id] = call + try { + Log.d(logTag, "executeFetch: id=$id, executing ${request.method} ${request.url}") + // Log headers and body for bridge requests + if (request.url.toString().contains("bridge.tonapi.io")) { + Log.d(logTag, "executeFetch: id=$id, Bridge POST - headers: ${request.headers}") + request.body?.let { body -> + val buffer = Buffer() + body.writeTo(buffer) + val bodyString = buffer.readUtf8() + Log.d(logTag, "executeFetch: id=$id, Bridge POST - body: ${bodyString.take(200)}") + } + } + val response = suspendCancellableCall(call) + Log.d(logTag, "executeFetch: id=$id, got response status=${response.code}") + val bodyBytes = response.body?.bytes() + val base64Body = + if (bodyBytes != null && bodyBytes.isNotEmpty()) { + Base64.encodeToString(bodyBytes, Base64.NO_WRAP) + } else { + null + } + val headersArray = JSONArray() + for ((name, value) in response.headers) { + headersArray.put(JSONArray().put(name).put(value)) + } + val meta = + JSONObject().apply { + put("status", response.code) + put("statusText", response.message) + put("headers", headersArray) + } + deliverFetchSuccess(id, meta.toString(), base64Body) + response.close() + } catch (err: Throwable) { + Log.e(logTag, "executeFetch: id=$id, error: ${err.message}", err) + deliverFetchError(id, err.message ?: "Network error") + } finally { + activeFetchCalls.remove(id) + } + } + + private suspend fun suspendCancellableCall(call: Call): Response = suspendCancellableCoroutine { continuation: CancellableContinuation -> + continuation.invokeOnCancellation { call.cancel() } + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isCancelled) { + return + } + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + }, + ) + } + + private fun deliverFetchSuccess( + id: Int, + metaJson: String, + bodyBase64: String?, + ) { + jsScope.launch { + val metaLiteral = JSONObject.quote(metaJson) + val bodyLiteral = if (bodyBase64 != null) JSONObject.quote(bodyBase64) else "null" + val script = + "globalThis.__walletkitResolveFetch($id,$metaLiteral,$bodyLiteral)" + try { + evaluate(script, "walletkit-fetch-success.js") + } catch (err: Throwable) { + Log.e(logTag, "Failed to deliver fetch success", err) + } + } + } + + internal fun deliverFetchError( + id: Int, + message: String, + ) { + jsScope.launch { + val errorLiteral = JSONObject.quote(message) + val script = "globalThis.__walletkitRejectFetch($id,$errorLiteral)" + try { + evaluate(script, "walletkit-fetch-error.js") + } catch (err: Throwable) { + Log.e(logTag, "Failed to deliver fetch error", err) + } + } + } + + private fun createTimer( + id: Int, + delay: Long, + repeat: Boolean, + ) { + lateinit var handle: TimerHandle + val task = Runnable { + jsScope.launch { + try { + evaluate("globalThis.__walletkitRunTimer($id)", "walletkit-timer.js") + } catch (err: Throwable) { + Log.e(logTag, "Timer $id execution failed", err) + } + } + if (repeat) { + rescheduleTimer(handle) + } else { + timers.remove(id) + } + } + handle = TimerHandle(id, repeat, delay, task) + handle.future = timerExecutor.schedule(task, delay.coerceAtLeast(0L), TimeUnit.MILLISECONDS) + timers[id] = handle + } + + private fun rescheduleTimer(handle: TimerHandle) { + handle.future?.cancel(false) + handle.future = timerExecutor.schedule(handle.task, handle.delayMillis, TimeUnit.MILLISECONDS) + } + + private fun deliverEventSourceOpen(id: Int) { + jsScope.launch { + try { + evaluate("globalThis.__walletkitEventSourceOnOpen($id)", "walletkit-eventsource-open.js") + } catch (err: Throwable) { + Log.e(logTag, "Failed to deliver EventSource open", err) + } + } + } + + private fun deliverEventSourceMessage( + id: Int, + eventType: String, + data: String, + lastEventId: String?, + ) { + jsScope.launch { + val typeLiteral = JSONObject.quote(eventType) + val dataLiteral = JSONObject.quote(data) + val idLiteral = lastEventId?.let { JSONObject.quote(it) } ?: "null" + val script = "globalThis.__walletkitEventSourceOnMessage($id,$typeLiteral,$dataLiteral,$idLiteral)" + try { + evaluate(script, "walletkit-eventsource-message.js") + } catch (err: Throwable) { + Log.e(logTag, "Failed to deliver EventSource message", err) + } + } + } + + internal fun deliverEventSourceError(id: Int, message: String?) { + jsScope.launch { + val messageLiteral = message?.let { JSONObject.quote(it) } ?: "null" + val script = "globalThis.__walletkitEventSourceOnError($id,$messageLiteral)" + try { + evaluate(script, "walletkit-eventsource-error.js") + } catch (err: Throwable) { + Log.e(logTag, "Failed to deliver EventSource error", err) + } + } + } + + internal fun deliverEventSourceClosed(id: Int, reason: String?) { + jsScope.launch { + val reasonLiteral = reason?.let { JSONObject.quote(it) } ?: "null" + val script = "globalThis.__walletkitEventSourceOnClose($id,$reasonLiteral)" + try { + evaluate(script, "walletkit-eventsource-close.js") + } catch (err: Throwable) { + Log.e(logTag, "Failed to deliver EventSource close", err) + } + } + } + + private fun cancelTimer(id: Int) { + timers.remove(id)?.future?.cancel(true) + } + + private data class TimerHandle( + val id: Int, + val repeat: Boolean, + val delayMillis: Long, + val task: Runnable, + @Volatile var future: ScheduledFuture<*>? = null, + ) + + internal inner class NativeEventSource { + fun open(url: String?, withCredentials: Boolean): Int { + val id = eventSourceIdGenerator.getAndIncrement() + return try { + val normalized = url?.takeIf { it.isNotBlank() } + if (normalized == null) { + Log.e(logTag, "EventSource open rejected due to empty URL") + deliverEventSourceError(id, "Invalid EventSource URL") + deliverEventSourceClosed(id, "invalid-url") + return id + } + val connection = EventSourceConnection(id, normalized, withCredentials) + eventSources[id] = connection + ioScope.launch { connection.start() } + id + } catch (err: Throwable) { + Log.e(logTag, "NativeEventSource.open error", err) + deliverEventSourceError(id, err.message ?: "EventSource open failed") + deliverEventSourceClosed(id, err::class.java.simpleName) + id + } + } + + fun close(id: Int) { + try { + eventSources.remove(id)?.close() + } catch (err: Throwable) { + Log.e(logTag, "NativeEventSource.close error", err) + } + } + } + + private inner class EventSourceConnection( + private val id: Int, + private val url: String, + private val withCredentials: Boolean, + ) { + @Volatile private var call: Call? = null + + @Volatile private var closed: Boolean = false + + suspend fun start() { + try { + val requestBuilder = + Request.Builder() + .url(url) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + if (!withCredentials) { + requestBuilder.header("Cookie", "") + } + val request = requestBuilder.build() + val call = httpClient.newCall(request) + this.call = call + val response = call.execute() + if (!response.isSuccessful) { + deliverEventSourceError(id, "HTTP ${response.code}") + response.close() + deliverEventSourceClosed(id, "http") + return + } + val body = response.body + if (body == null) { + deliverEventSourceError(id, "Empty EventSource body") + response.close() + deliverEventSourceClosed(id, "empty") + return + } + deliverEventSourceOpen(id) + try { + readEventStream(body.source()) + } finally { + body.close() + response.close() + } + if (!closed) { + deliverEventSourceClosed(id, null) + } + } catch (err: Throwable) { + if (!closed) { + deliverEventSourceError(id, err.message ?: "EventSource error") + deliverEventSourceClosed(id, err::class.java.simpleName) + } + } finally { + eventSources.remove(id, this) + } + } + + fun close() { + closed = true + call?.cancel() + } + + private fun readEventStream(source: BufferedSource) { + var eventType = "message" + val dataBuilder = StringBuilder() + var lastEventId: String? = null + while (!closed) { + val line = + try { + source.readUtf8Line() + } catch (err: IOException) { + if (!closed) { + throw err + } + null + } + ?: break + + if (line.isEmpty()) { + if (dataBuilder.isNotEmpty()) { + val payload = + if (dataBuilder[dataBuilder.length - 1] == '\n') { + dataBuilder.substring(0, dataBuilder.length - 1) + } else { + dataBuilder.toString() + } + deliverEventSourceMessage(id, eventType.ifBlank { "message" }, payload, lastEventId) + dataBuilder.setLength(0) + } + eventType = "message" + continue + } + + if (line.startsWith(":")) { + continue + } + + val delimiterIndex = line.indexOf(':') + val field: String + var value = "" + if (delimiterIndex == -1) { + field = line + } else { + field = line.substring(0, delimiterIndex) + value = line.substring(delimiterIndex + 1) + if (value.startsWith(" ")) { + value = value.substring(1) + } + } + + when (field) { + "event" -> eventType = value.ifBlank { "message" } + "data" -> { + dataBuilder.append(value) + dataBuilder.append('\n') + } + "id" -> lastEventId = value + "retry" -> Unit + } + } + + if (dataBuilder.isNotEmpty()) { + val payload = + if (dataBuilder[dataBuilder.length - 1] == '\n') { + dataBuilder.substring(0, dataBuilder.length - 1) + } else { + dataBuilder.toString() + } + deliverEventSourceMessage(id, eventType.ifBlank { "message" }, payload, lastEventId) + dataBuilder.setLength(0) + } + } + } + + private data class BridgeResponse(val result: JSONObject) + + companion object { + private const val QUICKJS_ASSET_DIR = "walletkit/quickjs/" + private const val DEFAULT_BUNDLE_ASSET = QUICKJS_ASSET_DIR + "index.js" + private const val BOOTSTRAP_SCRIPT_ASSET = QUICKJS_ASSET_DIR + "bootstrap.js" + private const val ENVIRONMENT_SHIM_ASSET = QUICKJS_ASSET_DIR + "environment.js" + private const val TEXT_ENCODING_ASSET = QUICKJS_ASSET_DIR + "text-encoding.js" + + private fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .retryOnConnectionFailure(true) + .readTimeout(0, TimeUnit.SECONDS) // Disable read timeout for EventSource + .build() + } +} + +// Unified Native Host for QuickJS +// Final attempt: Regular class, no constructor params, instance methods (not static) +// Let QuickJS create its own instance via reflection +/** + * Internal native host for QuickJS JavaScript engine. + * @suppress Internal implementation class. Not part of public API. + */ +internal class QuickJsNativeHost { + private val random = SecureRandom() + + companion object { + + var engine: QuickJsWalletKitEngine? = null + } + + // ============ Bridge Methods ============ + fun postMessage(json: String) { + val eng = engine ?: return + try { + // Check if this is a formatted log message (starts with [level]) + if (json.startsWith("[") && json.contains("]")) { + val closeBracket = json.indexOf(']') + if (closeBracket > 0 && closeBracket < 20) { + val level = json.substring(1, closeBracket) + val message = json.substring(closeBracket + 1).trim() + when (level) { + "error" -> Log.e(eng.logTag, message) + "warn" -> Log.w(eng.logTag, message) + "info" -> Log.i(eng.logTag, message) + "debug" -> Log.d(eng.logTag, message) + else -> Log.d(eng.logTag, json) + } + return + } + } + + // Try parsing as JSON + val payload = JSONObject(json) + val kind = payload.optString("kind") + + when (kind) { + "ready" -> eng.handleReady(payload) + "event" -> payload.optJSONObject("event")?.let { eng.handleEvent(it) } + "response" -> eng.handleResponse(payload.optString("id"), payload) + } + } catch (err: JSONException) { + Log.d(eng.logTag, "Non-JSON message from QuickJS: ${json.take(200)}") + } catch (err: Throwable) { + Log.e(eng.logTag, "NativeHost.postMessage error", err) + } + } + + // ============ Console Methods ============ + + fun consoleLog(message: String) { + postMessage(message) + } + + // ============ Base64 Methods ============ + + fun base64Encode(value: String): String = try { + val bytes = value.toByteArray(Charsets.ISO_8859_1) + Base64.encodeToString(bytes, Base64.NO_WRAP) + } catch (err: Throwable) { + engine?.logTag?.let { Log.w(it, "base64Encode failed: ${err.message}") } + "" + } + + fun base64Decode(value: String): String { + return try { + if (value.isBlank()) return "" + val bytes = Base64.decode(value, Base64.NO_WRAP) + String(bytes, Charsets.ISO_8859_1) + } catch (err: Throwable) { + engine?.logTag?.let { Log.w(it, "base64Decode failed: ${err.message}") } + "" + } + } + + // ============ Crypto Methods ============ + + fun cryptoRandomBytes(lengthStr: String): String { + Log.d("QuickJsNativeHost", "cryptoRandomBytes called with: $lengthStr") + val eng = engine + if (eng == null) { + Log.e("QuickJsNativeHost", "engine is null!") + return "" + } + Log.d(eng.logTag, "cryptoRandomBytes: engine is set, processing...") + return try { + val length = lengthStr.toIntOrNull() ?: 32 + Log.d(eng.logTag, "cryptoRandomBytes parsed length: $length") + val buffer = ByteArray(length.coerceAtLeast(0)) + random.nextBytes(buffer) + val result = Base64.encodeToString(buffer, Base64.NO_WRAP) + Log.d(eng.logTag, "cryptoRandomBytes returning ${result.length} chars") + result + } catch (err: Throwable) { + Log.e(eng.logTag, "cryptoRandomBytes error", err) + "" + } + } + + fun cryptoRandomUuid(): String = try { + UUID.randomUUID().toString() + } catch (err: Throwable) { + engine?.logTag?.let { Log.e(it, "cryptoRandomUuid error", err) } + "" + } + + fun cryptoPbkdf2Sha512(keyBase64: String, saltBase64: String, iterations: Int, keyLen: Int): String { + val eng = engine ?: return "" + Log.d(eng.logTag, "cryptoPbkdf2Sha512 called: iterations=$iterations, keyLen=$keyLen") + return try { + val key = Base64.decode(keyBase64, Base64.NO_WRAP) + val salt = Base64.decode(saltBase64, Base64.NO_WRAP) + + val spec = PBEKeySpec( + String(key, Charsets.UTF_8).toCharArray(), + salt, + iterations, + keyLen * 8, + ) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") + val derived = factory.generateSecret(spec).encoded + + val result = Base64.encodeToString(derived, Base64.NO_WRAP) + Log.d(eng.logTag, "cryptoPbkdf2Sha512 success, result length=${result.length}") + result + } catch (err: Throwable) { + Log.e(eng.logTag, "cryptoPbkdf2Sha512 error", err) + "" + } + } + + fun cryptoHmacSha512(keyBase64: String, dataBase64: String): String { + val eng = engine ?: return "" + return try { + val key = Base64.decode(keyBase64, Base64.NO_WRAP) + val data = Base64.decode(dataBase64, Base64.NO_WRAP) + + val mac = Mac.getInstance("HmacSHA512") + val secretKey = SecretKeySpec(key, "HmacSHA512") + mac.init(secretKey) + val result = mac.doFinal(data) + + Base64.encodeToString(result, Base64.NO_WRAP) + } catch (err: Throwable) { + Log.e(eng.logTag, "cryptoHmacSha512 error", err) + "" + } + } + + fun cryptoSha256(dataBase64: String): String { + val eng = engine ?: return "" + return try { + val data = Base64.decode(dataBase64, Base64.NO_WRAP) + val digest = MessageDigest.getInstance("SHA-256") + val result = digest.digest(data) + Base64.encodeToString(result, Base64.NO_WRAP) + } catch (err: Throwable) { + Log.e(eng.logTag, "cryptoSha256 error", err) + "" + } + } + + // ============ Timer Methods ============ + + fun timerRequest(paramsJson: String): String { + val eng = engine ?: return "-1" + return try { + val params = JSONObject(paramsJson) + val delayMs = params.optDouble("delay", 0.0) + val repeat = params.optBoolean("repeat", false) + eng.nativeTimers.request(delayMs, repeat).toString() + } catch (err: Throwable) { + Log.e(eng.logTag, "timerRequest error", err) + "-1" + } + } + + fun timerClear(idStr: String) { + val eng = engine ?: return + try { + val id = idStr.toIntOrNull() ?: return + eng.nativeTimers.clear(id) + } catch (err: Throwable) { + Log.e(eng.logTag, "timerClear error", err) + } + } + + // ============ Fetch Methods ============ + + fun fetchPerform(requestJson: String): String { + val eng = engine ?: return "-1" + return try { + eng.nativeFetch.perform(requestJson).toString() + } catch (err: Throwable) { + Log.e(eng.logTag, "fetchPerform error", err) + val requestId = eng.fetchIdGenerator.getAndIncrement() + eng.deliverFetchError(requestId, err.message ?: "Fetch request failed") + requestId.toString() + } + } + + fun fetchAbort(idStr: String) { + val eng = engine ?: return + try { + val id = idStr.toIntOrNull() ?: return + eng.nativeFetch.abort(id) + } catch (err: Throwable) { + Log.e(eng.logTag, "fetchAbort error", err) + } + } + + // ============ EventSource Methods ============ + + fun eventSourceOpen(paramsJson: String): String { + val eng = engine ?: return "-1" + return try { + val params = JSONObject(paramsJson) + val url = params.optString("url") + val withCredentials = params.optBoolean("withCredentials", false) + eng.nativeEventSource.open(url, withCredentials).toString() + } catch (err: Throwable) { + Log.e(eng.logTag, "eventSourceOpen error", err) + val id = eng.eventSourceIdGenerator.getAndIncrement() + eng.deliverEventSourceError(id, err.message ?: "EventSource open failed") + eng.deliverEventSourceClosed(id, err::class.java.simpleName) + id.toString() + } + } + + fun eventSourceClose(idStr: String) { + val eng = engine ?: return + try { + val id = idStr.toIntOrNull() ?: return + eng.nativeEventSource.close(id) + } catch (err: Throwable) { + Log.e(eng.logTag, "eventSourceClose error", err) + } + } + + // ============ LocalStorage Methods ============ + + fun localStorageGetItem(key: String): String? { + val eng = engine ?: return null + return try { + val prefs = eng.applicationContext.getSharedPreferences("walletkit_localStorage", Context.MODE_PRIVATE) + prefs.getString(key, null) + } catch (err: Throwable) { + Log.e(eng.logTag, "localStorageGetItem error", err) + null + } + } + + fun localStorageSetItem(key: String, value: String) { + val eng = engine ?: return + try { + val prefs = eng.applicationContext.getSharedPreferences("walletkit_localStorage", Context.MODE_PRIVATE) + prefs.edit { + putString(key, value) + } + } catch (err: Throwable) { + Log.e(eng.logTag, "localStorageSetItem error", err) + } + } + + fun localStorageRemoveItem(key: String) { + val eng = engine ?: return + try { + val prefs = eng.applicationContext.getSharedPreferences("walletkit_localStorage", Context.MODE_PRIVATE) + prefs.edit { + remove(key) + } + } catch (err: Throwable) { + Log.e(eng.logTag, "localStorageRemoveItem error", err) + } + } + + fun localStorageClear() { + val eng = engine ?: return + try { + val prefs = eng.applicationContext.getSharedPreferences("walletkit_localStorage", Context.MODE_PRIVATE) + prefs.edit { + clear() + } + } catch (err: Throwable) { + Log.e(eng.logTag, "localStorageClear error", err) + } + } + + fun localStorageKey(index: Int): String? { + val eng = engine ?: return null + return try { + val prefs = eng.applicationContext.getSharedPreferences("walletkit_localStorage", Context.MODE_PRIVATE) + val keys = prefs.all.keys.toList() + if (index >= 0 && index < keys.size) keys[index] else null + } catch (err: Throwable) { + Log.e(eng.logTag, "localStorageKey error", err) + null + } + } + + fun localStorageLength(): Int { + val eng = engine ?: return 0 + return try { + val prefs = eng.applicationContext.getSharedPreferences("walletkit_localStorage", Context.MODE_PRIVATE) + prefs.all.size + } catch (err: Throwable) { + Log.e(eng.logTag, "localStorageLength error", err) + 0 + } + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJs.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJs.kt new file mode 100644 index 000000000..a220e6b9e --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJs.kt @@ -0,0 +1,123 @@ +package io.ton.walletkit.presentation.impl.quickjs + +import java.io.Closeable +import java.lang.reflect.Method +import java.util.concurrent.atomic.AtomicBoolean + +private val IGNORED_METHOD_NAMES = setOf("equals", "hashCode", "toString", "wait", "notify", "notifyAll") + +/** + * QuickJS engine wrapper. + * @suppress Internal implementation class. Not part of public API. + */ +internal class QuickJs private constructor(private var nativePointer: Long) : Closeable { + private val closed = AtomicBoolean(false) + + init { + check(nativePointer != 0L) { "Invalid QuickJs pointer" } + } + + fun evaluate(script: String, filename: String = ""): Any? { + require(script.isNotEmpty()) { "script cannot be empty" } + val pointer = ensureOpen() + return nativeEvaluate(pointer, script, filename) + } + + fun executePendingJob(): Int { + val pointer = ensureOpen() + return nativeExecutePendingJob(pointer) + } + + fun set(name: String, type: Class<*>, instance: Any) { + require(name.isNotBlank()) { "name cannot be blank" } + val pointer = ensureOpen() + require(type.isInstance(instance)) { "instance is not of type ${type.name}" } + val methods = type.declaredMethods.filter { method -> + val name = method.name + if (name in IGNORED_METHOD_NAMES) { + return@filter false + } + !method.isSynthetic && !method.isBridge && method.parameterTypes.all { isSupportedType(it) } && + isSupportedReturnType(method.returnType) + } + if (methods.isEmpty()) { + return + } + for (method in methods) { + method.isAccessible = true + nativeRegister( + pointer = pointer, + objectName = name, + methodName = method.name, + method = method, + instance = instance, + parameterTypes = method.parameterTypes, + returnType = method.returnType, + ) + } + } + + override fun close() { + if (closed.compareAndSet(false, true)) { + val pointer = nativePointer + nativePointer = 0L + if (pointer != 0L) { + nativeDestroy(pointer) + } + } + } + + private fun ensureOpen(): Long { + check(!closed.get()) { "QuickJs instance already closed" } + return nativePointer + } + + private fun isSupportedType(type: Class<*>): Boolean = when (type) { + java.lang.String::class.java, + java.lang.Boolean::class.java, + java.lang.Boolean.TYPE, + java.lang.Integer::class.java, + java.lang.Integer.TYPE, + java.lang.Long::class.java, + java.lang.Long.TYPE, + java.lang.Double::class.java, + java.lang.Double.TYPE, + -> true + else -> false + } + + private fun isSupportedReturnType(type: Class<*>): Boolean = type == java.lang.Void.TYPE || type == java.lang.Void::class.java || isSupportedType(type) + + private external fun nativeEvaluate(pointer: Long, script: String, filename: String): Any? + + private external fun nativeExecutePendingJob(pointer: Long): Int + + private external fun nativeRegister( + pointer: Long, + objectName: String, + methodName: String, + method: Method, + instance: Any, + parameterTypes: Array>, + returnType: Class<*>, + ) + + private external fun nativeDestroy(pointer: Long) + + companion object { + init { + QuickJsNativeLoader.load() + } + + fun create(): QuickJs { + val pointer = nativeCreate() + if (pointer == 0L) { + throw QuickJsException("Failed to create QuickJs runtime") + } + return QuickJs(pointer) + } + + @JvmStatic + private external fun nativeCreate(): Long + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJsException.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJsException.kt new file mode 100644 index 000000000..35ee8b88f --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJsException.kt @@ -0,0 +1,7 @@ +package io.ton.walletkit.presentation.impl.quickjs + +/** + * QuickJS exception. + * @suppress Internal implementation class. Not part of public API. + */ +internal class QuickJsException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJsNativeLoader.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJsNativeLoader.kt new file mode 100644 index 000000000..65a2c0884 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/full/java/io/ton/walletkit/presentation/impl/quickjs/QuickJsNativeLoader.kt @@ -0,0 +1,13 @@ +package io.ton.walletkit.presentation.impl.quickjs + +import java.util.concurrent.atomic.AtomicBoolean + +internal object QuickJsNativeLoader { + private val loaded = AtomicBoolean(false) + + fun load() { + if (loaded.compareAndSet(false, true)) { + System.loadLibrary("walletkitquickjs") + } + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/assets/.gitignore b/apps/androidkit/TONWalletKit-Android/bridge/src/main/assets/.gitignore new file mode 100644 index 000000000..480610157 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/assets/.gitignore @@ -0,0 +1 @@ +walletkit/ diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/cpp/CMakeLists.txt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..352f64629 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/cpp/CMakeLists.txt @@ -0,0 +1,61 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(walletkitquickjs) + +set(CMAKE_C_STANDARD 17) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +set(QUICKJS_NG_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/quickjs-ng") + +# Check if QuickJS sources exist - if not, skip native build +# This allows WebView variant to build without QuickJS sources +if (NOT EXISTS "${QUICKJS_NG_DIR}/quickjs.h") + message(WARNING "quickjs-ng sources are missing. Skipping native build. Run ./gradlew :bridge:prepareQuickJs to build full variant with QuickJS support.") + return() +endif() + +set(QUICKJS_NG_SOURCES + ${QUICKJS_NG_DIR}/quickjs.c + ${QUICKJS_NG_DIR}/libregexp.c + ${QUICKJS_NG_DIR}/libunicode.c + ${QUICKJS_NG_DIR}/cutils.c + ${QUICKJS_NG_DIR}/xsum.c +) + +add_library(walletkitquickjs SHARED + quickjs_bridge.cpp + ${QUICKJS_NG_SOURCES} +) + +target_include_directories(walletkitquickjs + PRIVATE + ${QUICKJS_NG_DIR} +) + +target_compile_definitions(walletkitquickjs + PRIVATE + CONFIG_VERSION="quickjs-ng v0.10.1" + _GNU_SOURCE +) + +target_compile_options(walletkitquickjs + PRIVATE + -Os + -fvisibility=hidden + -fexceptions + -Wno-implicit-fallthrough + -Wno-missing-field-initializers + -Wno-unused-parameter +) + +target_link_libraries(walletkitquickjs + log +) + +target_link_options(walletkitquickjs + PRIVATE + -Wl,-z,max-page-size=16384 + -Wl,-z,common-page-size=4096 + -Wl,--hash-style=both +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/cpp/quickjs_bridge.cpp b/apps/androidkit/TONWalletKit-Android/bridge/src/main/cpp/quickjs_bridge.cpp new file mode 100644 index 000000000..714899350 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/cpp/quickjs_bridge.cpp @@ -0,0 +1,817 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "quickjs.h" +} + +namespace { + +constexpr const char* kLogTag = "WalletKitQuickJs"; +const jint kRequiredJniVersion = JNI_VERSION_1_6; + +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, kLogTag, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, kLogTag, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, kLogTag, __VA_ARGS__) + +enum class ValueKind { + kVoid, + kString, + kBoolean, + kInt, + kLong, + kDouble, +}; + +struct JavaRefs { + jclass methodClass = nullptr; + jmethodID methodInvoke = nullptr; + + jclass stringClass = nullptr; + + jclass booleanClass = nullptr; + jclass booleanTypeClass = nullptr; + jmethodID booleanValueOf = nullptr; + jmethodID booleanBooleanValue = nullptr; + + jclass integerClass = nullptr; + jclass integerTypeClass = nullptr; + jmethodID integerValueOf = nullptr; + jmethodID integerIntValue = nullptr; + + jclass longClass = nullptr; + jclass longTypeClass = nullptr; + jmethodID longValueOf = nullptr; + jmethodID longLongValue = nullptr; + + jclass doubleClass = nullptr; + jclass doubleTypeClass = nullptr; + jmethodID doubleValueOf = nullptr; + jmethodID doubleDoubleValue = nullptr; + + jclass voidTypeClass = nullptr; + + jclass objectClass = nullptr; + + jclass throwableClass = nullptr; + jmethodID throwableGetMessage = nullptr; + + jclass quickJsExceptionClass = nullptr; +}; + +struct MethodBinding { + int id = 0; + std::string objectName; + std::string methodName; + jobject method = nullptr; + jobject instance = nullptr; + std::vector parameterKinds; + ValueKind returnKind = ValueKind::kVoid; +}; + +struct QuickJsContext { + JavaVM* vm = nullptr; + JSRuntime* runtime = nullptr; + JSContext* context = nullptr; + JavaRefs refs; + std::mutex mutex; + std::unordered_map> bindings; + int nextBindingId = 1; +}; + +QuickJsContext* getContext(JSContext* ctx) { + return static_cast(JS_GetContextOpaque(ctx)); +} + +JNIEnv* requireEnv(JavaVM* vm) { + if (vm == nullptr) { + return nullptr; + } + JNIEnv* env = nullptr; + jint status = vm->GetEnv(reinterpret_cast(&env), kRequiredJniVersion); + if (status == JNI_OK) { + return env; + } + if (status == JNI_EDETACHED) { +#ifdef __ANDROID__ + if (vm->AttachCurrentThread(&env, nullptr) != JNI_OK) { + LOGE("Unable to attach current thread to JVM"); + return nullptr; + } + return env; +#else + return nullptr; +#endif + } + LOGE("Failed to obtain JNIEnv (status=%d)", status); + return nullptr; +} + +void clearJavaException(JNIEnv* env) { + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } +} + +void throwQuickJsException(JNIEnv* env, const JavaRefs& refs, const std::string& message) { + if (refs.quickJsExceptionClass == nullptr) { + LOGE("QuickJsException class not cached"); + env->ThrowNew(env->FindClass("java/lang/RuntimeException"), message.c_str()); + return; + } + env->ThrowNew(refs.quickJsExceptionClass, message.c_str()); +} + +std::string toUtfString(JNIEnv* env, jstring value) { + if (value == nullptr) { + return std::string(); + } + const char* chars = env->GetStringUTFChars(value, nullptr); + if (chars == nullptr) { + return std::string(); + } + std::string result(chars); + env->ReleaseStringUTFChars(value, chars); + return result; +} + +std::string jsExceptionToString(JSContext* ctx, JSValueConst exception) { + std::string message; + JSValue messageValue = JS_GetPropertyStr(ctx, exception, "message"); + if (!JS_IsException(messageValue) && !JS_IsUndefined(messageValue)) { + const char* messageChars = JS_ToCString(ctx, messageValue); + if (messageChars != nullptr) { + message.assign(messageChars); + JS_FreeCString(ctx, messageChars); + } + } + JS_FreeValue(ctx, messageValue); + if (message.empty()) { + const char* fallback = JS_ToCString(ctx, exception); + if (fallback != nullptr) { + message.assign(fallback); + JS_FreeCString(ctx, fallback); + } + } + if (message.empty()) { + message = "QuickJS evaluation failed"; + } + return message; +} + +ValueKind classifyParameter(JNIEnv* env, const JavaRefs& refs, jobject clazzObj) { + if (clazzObj == nullptr) { + return ValueKind::kVoid; + } + jclass clazz = static_cast(clazzObj); + if (env->IsSameObject(clazz, refs.stringClass)) { + return ValueKind::kString; + } + if (env->IsSameObject(clazz, refs.booleanClass) || env->IsSameObject(clazz, refs.booleanTypeClass)) { + return ValueKind::kBoolean; + } + if (env->IsSameObject(clazz, refs.integerClass) || env->IsSameObject(clazz, refs.integerTypeClass)) { + return ValueKind::kInt; + } + if (env->IsSameObject(clazz, refs.longClass) || env->IsSameObject(clazz, refs.longTypeClass)) { + return ValueKind::kLong; + } + if (env->IsSameObject(clazz, refs.doubleClass) || env->IsSameObject(clazz, refs.doubleTypeClass)) { + return ValueKind::kDouble; + } + return ValueKind::kVoid; +} + +jobject convertJsToJava(JNIEnv* env, QuickJsContext* quickContext, ValueKind kind, JSValueConst value) { + JSContext* ctx = quickContext->context; + switch (kind) { + case ValueKind::kString: { + if (JS_IsNull(value) || JS_IsUndefined(value)) { + return nullptr; + } + const char* chars = JS_ToCString(ctx, value); + if (chars == nullptr) { + return nullptr; + } + jstring result = env->NewStringUTF(chars); + JS_FreeCString(ctx, chars); + return result; + } + case ValueKind::kBoolean: { + int32_t boolValue = JS_ToBool(ctx, value); + if (boolValue < 0) { + return nullptr; + } + return env->CallStaticObjectMethod(quickContext->refs.booleanClass, quickContext->refs.booleanValueOf, (jboolean)(boolValue != 0)); + } + case ValueKind::kInt: { + int32_t intValue; + if (JS_ToInt32(ctx, &intValue, value) < 0) { + return nullptr; + } + return env->CallStaticObjectMethod(quickContext->refs.integerClass, quickContext->refs.integerValueOf, (jint)intValue); + } + case ValueKind::kLong: { + int64_t longValue; + if (JS_ToInt64(ctx, &longValue, value) < 0) { + return nullptr; + } + return env->CallStaticObjectMethod(quickContext->refs.longClass, quickContext->refs.longValueOf, (jlong)longValue); + } + case ValueKind::kDouble: { + double doubleValue; + if (JS_ToFloat64(ctx, &doubleValue, value) < 0) { + return nullptr; + } + return env->CallStaticObjectMethod(quickContext->refs.doubleClass, quickContext->refs.doubleValueOf, (jdouble)doubleValue); + } + case ValueKind::kVoid: + default: + return nullptr; + } +} + +JSValue convertJavaToJs(JNIEnv* env, QuickJsContext* quickContext, ValueKind kind, jobject value) { + LOGI("convertJavaToJs: kind=%d, value=%p", static_cast(kind), value); + JSContext* ctx = quickContext->context; + switch (kind) { + case ValueKind::kString: { + if (value == nullptr) { + LOGI("convertJavaToJs: String value is null"); + return JS_NULL; + } + jstring stringValue = static_cast(value); + const char* chars = env->GetStringUTFChars(stringValue, nullptr); + if (chars == nullptr) { + LOGE("convertJavaToJs: GetStringUTFChars returned null"); + return JS_NULL; + } + LOGI("convertJavaToJs: String value='%s'", chars); + JSValue jsValue = JS_NewString(ctx, chars); + env->ReleaseStringUTFChars(stringValue, chars); + return jsValue; + } + case ValueKind::kBoolean: { + if (value == nullptr) { + return JS_FALSE; + } + jboolean boolValue = env->CallBooleanMethod(value, quickContext->refs.booleanBooleanValue); + return JS_NewBool(ctx, boolValue == JNI_TRUE); + } + case ValueKind::kInt: { + if (value == nullptr) { + return JS_NewInt32(ctx, 0); + } + jint intValue = env->CallIntMethod(value, quickContext->refs.integerIntValue); + return JS_NewInt32(ctx, intValue); + } + case ValueKind::kLong: { + if (value == nullptr) { + return JS_NewInt32(ctx, 0); + } + jlong longValue = env->CallLongMethod(value, quickContext->refs.longLongValue); + return JS_NewInt64(ctx, longValue); + } + case ValueKind::kDouble: { + if (value == nullptr) { + return JS_NewFloat64(ctx, 0.0); + } + jdouble doubleValue = env->CallDoubleMethod(value, quickContext->refs.doubleDoubleValue); + return JS_NewFloat64(ctx, doubleValue); + } + case ValueKind::kVoid: + default: + return JS_UNDEFINED; + } +} + +JSValue throwTypeError(JSContext* ctx, const std::string& message) { + return JS_ThrowTypeError(ctx, "%s", message.c_str()); +} + +std::string describeThrowable(JNIEnv* env, QuickJsContext* quickContext, jthrowable throwable) { + if (throwable == nullptr) { + return "Host method threw an exception"; + } + jobject messageObject = env->CallObjectMethod(throwable, quickContext->refs.throwableGetMessage); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return "Host method threw an exception"; + } + std::string message = toUtfString(env, static_cast(messageObject)); + env->DeleteLocalRef(messageObject); + if (message.empty()) { + return "Host method threw an exception"; + } + return message; +} + +JSValue invokeBinding(JSContext* ctx, QuickJsContext* quickContext, MethodBinding* binding, int argc, JSValueConst* argv) { + // LOGI("invokeBinding: start for %s.%s", binding->objectName.c_str(), binding->methodName.c_str()); + JNIEnv* env = requireEnv(quickContext->vm); + if (env == nullptr) { + LOGE("invokeBinding: Failed to obtain JNI environment"); + return JS_ThrowInternalError(ctx, "Failed to obtain JNI environment"); + } + // LOGI("invokeBinding: JNI env obtained"); + + const size_t paramCount = binding->parameterKinds.size(); + // LOGI("invokeBinding: paramCount=%zu", paramCount); + jobjectArray argsArray = nullptr; + if (paramCount > 0) { + argsArray = env->NewObjectArray(static_cast(paramCount), quickContext->refs.objectClass, nullptr); + if (argsArray == nullptr) { + LOGE("invokeBinding: Unable to allocate argument array"); + return JS_ThrowInternalError(ctx, "Unable to allocate argument array"); + } + // LOGI("invokeBinding: created args array"); + } + + std::vector localArgs; + localArgs.reserve(paramCount); + + for (size_t index = 0; index < paramCount; ++index) { + JSValueConst argument = (index < static_cast(argc)) ? argv[index] : JS_UNDEFINED; + jobject converted = convertJsToJava(env, quickContext, binding->parameterKinds[index], argument); + if (converted == nullptr && (binding->parameterKinds[index] != ValueKind::kString)) { + LOGE("invokeBinding: Unable to convert argument %zu", index); + if (argsArray != nullptr) { + env->DeleteLocalRef(argsArray); + } + for (jobject item : localArgs) { + env->DeleteLocalRef(item); + } + return throwTypeError(ctx, "Unable to convert argument for method " + binding->objectName + "." + binding->methodName); + } + localArgs.push_back(converted); + if (argsArray != nullptr) { + env->SetObjectArrayElement(argsArray, static_cast(index), converted); + } + } + // LOGI("invokeBinding: all arguments converted"); + + jobjectArray invocationArgs = nullptr; + if (paramCount > 0) { + invocationArgs = argsArray; + } + + // LOGI("invokeBinding: about to CallObjectMethod"); + // LOGI("invokeBinding: binding->method=%p, methodInvoke=%p, binding->instance=%p", + // binding->method, quickContext->refs.methodInvoke, binding->instance); + jobject invocationResult = env->CallObjectMethod( + binding->method, + quickContext->refs.methodInvoke, + binding->instance, + invocationArgs); + // LOGI("invokeBinding: CallObjectMethod returned, result=%p", invocationResult); + + for (jobject localRef : localArgs) { + if (localRef != nullptr) { + env->DeleteLocalRef(localRef); + } + } + if (argsArray != nullptr) { + env->DeleteLocalRef(argsArray); + } + + if (env->ExceptionCheck()) { + LOGE("invokeBinding: Exception occurred during method invocation"); + jthrowable throwable = env->ExceptionOccurred(); + env->ExceptionClear(); + std::string message = describeThrowable(env, quickContext, throwable); + env->DeleteLocalRef(throwable); + LOGE("invokeBinding: Exception message: %s", message.c_str()); + return JS_ThrowInternalError(ctx, "%s", message.c_str()); + } + // LOGI("invokeBinding: no exception"); + + JSValue jsResult = JS_UNDEFINED; + if (binding->returnKind != ValueKind::kVoid) { + // LOGI("invokeBinding: converting return value, kind=%d", static_cast(binding->returnKind)); + jsResult = convertJavaToJs(env, quickContext, binding->returnKind, invocationResult); + // LOGI("invokeBinding: converted return value"); + } + + if (invocationResult != nullptr) { + env->DeleteLocalRef(invocationResult); + } + + // LOGI("invokeBinding: complete"); + return jsResult; +} + +JSValue methodDispatcher(JSContext* ctx, JSValueConst /*this_val*/, int argc, JSValueConst* argv, int magic) { + // LOGI("methodDispatcher: called with magic=%d, argc=%d", magic, argc); + QuickJsContext* quickContext = getContext(ctx); + if (quickContext == nullptr) { + LOGE("methodDispatcher: QuickJs context is null"); + return JS_ThrowInternalError(ctx, "QuickJs context missing"); + } + MethodBinding* binding = nullptr; + { + std::lock_guard lock(quickContext->mutex); + auto iterator = quickContext->bindings.find(magic); + if (iterator != quickContext->bindings.end()) { + binding = iterator->second.get(); + // LOGI("methodDispatcher: found binding for %s.%s (method=%p, instance=%p)", + // binding->objectName.c_str(), binding->methodName.c_str(), + // binding->method, binding->instance); + } + } + if (binding == nullptr) { + LOGE("methodDispatcher: Host binding not found for magic=%d", magic); + return JS_ThrowInternalError(ctx, "Host binding not found"); + } + // LOGI("methodDispatcher: invoking %s.%s", binding->objectName.c_str(), binding->methodName.c_str()); + JSValue result = invokeBinding(ctx, quickContext, binding, argc, argv); + // LOGI("methodDispatcher: invocation complete"); + return result; +} + +bool ensureGlobalObject(JSContext* ctx, const std::string& name, JSValue& objectValue) { + JSValue globalObject = JS_GetGlobalObject(ctx); + JSValue existing = JS_GetPropertyStr(ctx, globalObject, name.c_str()); + if (JS_IsUndefined(existing) || JS_IsNull(existing)) { + LOGI("ensureGlobalObject: creating new object '%s'", name.c_str()); + JS_FreeValue(ctx, existing); + existing = JS_NewObject(ctx); + if (JS_IsException(existing)) { + JS_FreeValue(ctx, globalObject); + return false; + } + if (JS_SetPropertyStr(ctx, globalObject, name.c_str(), JS_DupValue(ctx, existing)) < 0) { + JS_FreeValue(ctx, existing); + JS_FreeValue(ctx, globalObject); + return false; + } + } else { + LOGI("ensureGlobalObject: reusing existing object '%s'", name.c_str()); + } + objectValue = existing; + JS_FreeValue(ctx, globalObject); + return true; +} + +bool cacheJavaReferences(JNIEnv* env, JavaRefs& refs) { + auto makeGlobal = [&](const char* name) -> jclass { + jclass local = env->FindClass(name); + if (local == nullptr) { + return nullptr; + } + jclass global = static_cast(env->NewGlobalRef(local)); + env->DeleteLocalRef(local); + return global; + }; + + refs.objectClass = makeGlobal("java/lang/Object"); + refs.methodClass = makeGlobal("java/lang/reflect/Method"); + refs.stringClass = makeGlobal("java/lang/String"); + refs.booleanClass = makeGlobal("java/lang/Boolean"); + refs.integerClass = makeGlobal("java/lang/Integer"); + refs.longClass = makeGlobal("java/lang/Long"); + refs.doubleClass = makeGlobal("java/lang/Double"); + refs.throwableClass = makeGlobal("java/lang/Throwable"); + refs.quickJsExceptionClass = makeGlobal("io/ton/walletkit/quickjs/QuickJsException"); + + if (refs.objectClass == nullptr || refs.methodClass == nullptr || refs.stringClass == nullptr || + refs.booleanClass == nullptr || refs.integerClass == nullptr || refs.longClass == nullptr || + refs.doubleClass == nullptr || refs.throwableClass == nullptr || refs.quickJsExceptionClass == nullptr) { + return false; + } + + jfieldID booleanTypeField = env->GetStaticFieldID(refs.booleanClass, "TYPE", "Ljava/lang/Class;"); + jfieldID integerTypeField = env->GetStaticFieldID(refs.integerClass, "TYPE", "Ljava/lang/Class;"); + jfieldID longTypeField = env->GetStaticFieldID(refs.longClass, "TYPE", "Ljava/lang/Class;"); + jfieldID doubleTypeField = env->GetStaticFieldID(refs.doubleClass, "TYPE", "Ljava/lang/Class;"); + + if (booleanTypeField == nullptr || integerTypeField == nullptr || longTypeField == nullptr || doubleTypeField == nullptr) { + return false; + } + + refs.booleanTypeClass = static_cast(env->NewGlobalRef(env->GetStaticObjectField(refs.booleanClass, booleanTypeField))); + refs.integerTypeClass = static_cast(env->NewGlobalRef(env->GetStaticObjectField(refs.integerClass, integerTypeField))); + refs.longTypeClass = static_cast(env->NewGlobalRef(env->GetStaticObjectField(refs.longClass, longTypeField))); + refs.doubleTypeClass = static_cast(env->NewGlobalRef(env->GetStaticObjectField(refs.doubleClass, doubleTypeField))); + + jclass voidClass = env->FindClass("java/lang/Void"); + if (voidClass == nullptr) { + return false; + } + jfieldID voidTypeField = env->GetStaticFieldID(voidClass, "TYPE", "Ljava/lang/Class;"); + if (voidTypeField == nullptr) { + env->DeleteLocalRef(voidClass); + return false; + } + jobject voidType = env->GetStaticObjectField(voidClass, voidTypeField); + env->DeleteLocalRef(voidClass); + refs.voidTypeClass = static_cast(env->NewGlobalRef(voidType)); + env->DeleteLocalRef(voidType); + + refs.methodInvoke = env->GetMethodID(refs.methodClass, "invoke", "(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"); + refs.booleanValueOf = env->GetStaticMethodID(refs.booleanClass, "valueOf", "(Z)Ljava/lang/Boolean;"); + refs.booleanBooleanValue = env->GetMethodID(refs.booleanClass, "booleanValue", "()Z"); + refs.integerValueOf = env->GetStaticMethodID(refs.integerClass, "valueOf", "(I)Ljava/lang/Integer;"); + refs.integerIntValue = env->GetMethodID(refs.integerClass, "intValue", "()I"); + refs.longValueOf = env->GetStaticMethodID(refs.longClass, "valueOf", "(J)Ljava/lang/Long;"); + refs.longLongValue = env->GetMethodID(refs.longClass, "longValue", "()J"); + refs.doubleValueOf = env->GetStaticMethodID(refs.doubleClass, "valueOf", "(D)Ljava/lang/Double;"); + refs.doubleDoubleValue = env->GetMethodID(refs.doubleClass, "doubleValue", "()D"); + refs.throwableGetMessage = env->GetMethodID(refs.throwableClass, "getMessage", "()Ljava/lang/String;"); + + return refs.methodInvoke != nullptr && refs.booleanValueOf != nullptr && refs.booleanBooleanValue != nullptr && + refs.integerValueOf != nullptr && refs.integerIntValue != nullptr && refs.longValueOf != nullptr && + refs.longLongValue != nullptr && refs.doubleValueOf != nullptr && refs.doubleDoubleValue != nullptr && + refs.throwableGetMessage != nullptr; +} + +void releaseJavaReferences(JNIEnv* env, JavaRefs& refs) { + auto release = [&](jclass& clazz) { + if (clazz != nullptr) { + env->DeleteGlobalRef(clazz); + clazz = nullptr; + } + }; + + release(refs.methodClass); + release(refs.stringClass); + release(refs.booleanClass); + release(refs.booleanTypeClass); + release(refs.integerClass); + release(refs.integerTypeClass); + release(refs.longClass); + release(refs.longTypeClass); + release(refs.doubleClass); + release(refs.doubleTypeClass); + release(refs.voidTypeClass); + release(refs.objectClass); + release(refs.throwableClass); + release(refs.quickJsExceptionClass); +} + +} // namespace + +extern "C" JNIEXPORT jlong JNICALL +Java_io_ton_walletkit_quickjs_QuickJs_nativeCreate(JNIEnv* env, jclass /*clazz*/) { + auto* quickContext = new QuickJsContext(); + if (env->GetJavaVM(&quickContext->vm) != JNI_OK) { + delete quickContext; + return 0; + } + if (!cacheJavaReferences(env, quickContext->refs)) { + delete quickContext; + return 0; + } + quickContext->runtime = JS_NewRuntime(); + if (quickContext->runtime == nullptr) { + delete quickContext; + return 0; + } + quickContext->context = JS_NewContext(quickContext->runtime); + if (quickContext->context == nullptr) { + JS_FreeRuntime(quickContext->runtime); + delete quickContext; + return 0; + } + + JS_SetContextOpaque(quickContext->context, quickContext); + + return reinterpret_cast(quickContext); +} + +extern "C" JNIEXPORT void JNICALL +Java_io_ton_walletkit_quickjs_QuickJs_nativeDestroy(JNIEnv* env, jobject /*thiz*/, jlong pointer) { + if (pointer == 0) { + return; + } + auto* quickContext = reinterpret_cast(pointer); + if (quickContext == nullptr) { + return; + } + { + std::lock_guard lock(quickContext->mutex); + for (auto& entry : quickContext->bindings) { + MethodBinding* binding = entry.second.get(); + if (binding->method != nullptr) { + env->DeleteGlobalRef(binding->method); + binding->method = nullptr; + } + if (binding->instance != nullptr) { + env->DeleteGlobalRef(binding->instance); + binding->instance = nullptr; + } + } + quickContext->bindings.clear(); + } + releaseJavaReferences(env, quickContext->refs); + if (quickContext->context != nullptr) { + JS_FreeContext(quickContext->context); + } + if (quickContext->runtime != nullptr) { + JS_FreeRuntime(quickContext->runtime); + } + delete quickContext; +} + +extern "C" JNIEXPORT jobject JNICALL +Java_io_ton_walletkit_quickjs_QuickJs_nativeEvaluate(JNIEnv* env, jobject /*thiz*/, jlong pointer, jstring script, jstring filename) { + if (pointer == 0) { + throwQuickJsException(env, JavaRefs{}, "QuickJs runtime has been destroyed"); + return nullptr; + } + auto* quickContext = reinterpret_cast(pointer); + const char* scriptChars = env->GetStringUTFChars(script, nullptr); + const char* filenameChars = env->GetStringUTFChars(filename, nullptr); + if (scriptChars == nullptr || filenameChars == nullptr) { + if (scriptChars != nullptr) { + env->ReleaseStringUTFChars(script, scriptChars); + } + if (filenameChars != nullptr) { + env->ReleaseStringUTFChars(filename, filenameChars); + } + throwQuickJsException(env, quickContext->refs, "Unable to access script characters"); + return nullptr; + } + JSValue result = JS_Eval(quickContext->context, scriptChars, strlen(scriptChars), filenameChars, JS_EVAL_TYPE_GLOBAL); + env->ReleaseStringUTFChars(script, scriptChars); + env->ReleaseStringUTFChars(filename, filenameChars); + + if (JS_IsException(result)) { + JSValue exception = JS_GetException(quickContext->context); + std::string message = jsExceptionToString(quickContext->context, exception); + JS_FreeValue(quickContext->context, exception); + JS_FreeValue(quickContext->context, result); + throwQuickJsException(env, quickContext->refs, message); + return nullptr; + } + + jobject javaResult = nullptr; + if (!JS_IsUndefined(result) && !JS_IsNull(result)) { + if (JS_IsString(result)) { + javaResult = convertJsToJava(env, quickContext, ValueKind::kString, result); + } else if (JS_IsNumber(result)) { + javaResult = convertJsToJava(env, quickContext, ValueKind::kDouble, result); + } else if (JS_IsBool(result)) { + javaResult = convertJsToJava(env, quickContext, ValueKind::kBoolean, result); + } + } + JS_FreeValue(quickContext->context, result); + return javaResult; +} + +extern "C" JNIEXPORT jint JNICALL +Java_io_ton_walletkit_quickjs_QuickJs_nativeExecutePendingJob(JNIEnv* env, jobject /*thiz*/, jlong pointer) { + if (pointer == 0) { + throwQuickJsException(env, JavaRefs{}, "QuickJs runtime has been destroyed"); + return -1; + } + auto* quickContext = reinterpret_cast(pointer); + if (quickContext->runtime == nullptr) { + throwQuickJsException(env, quickContext->refs, "QuickJs runtime is not initialised"); + return -1; + } + JSContext* jobContext = nullptr; + int result = JS_ExecutePendingJob(quickContext->runtime, &jobContext); + if (result < 0) { + if (jobContext != nullptr) { + JSValue exception = JS_GetException(jobContext); + std::string message = jsExceptionToString(jobContext, exception); + JS_FreeValue(jobContext, exception); + throwQuickJsException(env, quickContext->refs, message); + } else { + throwQuickJsException(env, quickContext->refs, "JS_ExecutePendingJob failed"); + } + return -1; + } + return result; +} + +extern "C" JNIEXPORT void JNICALL +Java_io_ton_walletkit_quickjs_QuickJs_nativeRegister(JNIEnv* env, jobject /*thiz*/, jlong pointer, jstring objectName, jstring methodName, jobject method, jobject instance, jobjectArray parameterTypes, jobject returnType) { + LOGI("nativeRegister: called"); + if (pointer == 0) { + throwQuickJsException(env, JavaRefs{}, "QuickJs runtime has been destroyed"); + return; + } + auto* quickContext = reinterpret_cast(pointer); + QuickJsContext& context = *quickContext; + + std::string objectNameUtf = toUtfString(env, objectName); + std::string methodNameUtf = toUtfString(env, methodName); + LOGI("nativeRegister: %s.%s", objectNameUtf.c_str(), methodNameUtf.c_str()); + if (objectNameUtf.empty() || methodNameUtf.empty()) { + throwQuickJsException(env, context.refs, "Object or method name cannot be empty"); + return; + } + + jsize parameterCount = parameterTypes == nullptr ? 0 : env->GetArrayLength(parameterTypes); + + auto binding = std::make_unique(); + binding->objectName = objectNameUtf; + binding->methodName = methodNameUtf; + LOGI("nativeRegister: method local ref=%p", method); + binding->method = env->NewGlobalRef(method); + LOGI("nativeRegister: method global ref=%p", binding->method); + binding->instance = env->NewGlobalRef(instance); + LOGI("nativeRegister: instance global ref=%p", binding->instance); + binding->parameterKinds.reserve(parameterCount); + + for (jsize index = 0; index < parameterCount; ++index) { + jobject parameterType = env->GetObjectArrayElement(parameterTypes, index); + ValueKind kind = classifyParameter(env, context.refs, parameterType); + env->DeleteLocalRef(parameterType); + if (kind == ValueKind::kVoid) { + throwQuickJsException(env, context.refs, "Unsupported parameter type for method " + binding->objectName + "." + binding->methodName); + env->DeleteGlobalRef(binding->method); + env->DeleteGlobalRef(binding->instance); + return; + } + binding->parameterKinds.push_back(kind); + } + + ValueKind returnKind = ValueKind::kVoid; + if (returnType != nullptr && !env->IsSameObject(returnType, context.refs.voidTypeClass)) { + returnKind = classifyParameter(env, context.refs, returnType); + if (returnKind == ValueKind::kVoid && !env->IsSameObject(returnType, context.refs.stringClass)) { + throwQuickJsException(env, context.refs, "Unsupported return type for method " + binding->objectName + "." + binding->methodName); + env->DeleteGlobalRef(binding->method); + env->DeleteGlobalRef(binding->instance); + return; + } + if (returnKind == ValueKind::kVoid && env->IsSameObject(returnType, context.refs.stringClass)) { + returnKind = ValueKind::kString; + } + } + binding->returnKind = returnKind; + + int bindingId; + { + std::lock_guard lock(context.mutex); + bindingId = context.nextBindingId++; + binding->id = bindingId; + context.bindings.emplace(bindingId, std::move(binding)); + } + LOGI("nativeRegister: assigned magic=%d to %s.%s", bindingId, objectNameUtf.c_str(), methodNameUtf.c_str()); + + JSValue objectValue; + if (!ensureGlobalObject(context.context, objectNameUtf, objectValue)) { + std::lock_guard lock(context.mutex); + auto iterator = context.bindings.find(bindingId); + if (iterator != context.bindings.end()) { + MethodBinding* stored = iterator->second.get(); + env->DeleteGlobalRef(stored->method); + env->DeleteGlobalRef(stored->instance); + context.bindings.erase(iterator); + } + throwQuickJsException(env, context.refs, "Failed to create host object " + objectNameUtf); + return; + } + + JSValue function = JS_NewCFunctionMagic( + context.context, + methodDispatcher, + methodNameUtf.c_str(), + static_cast(parameterCount), + JS_CFUNC_generic_magic, + bindingId); + LOGI("nativeRegister: created JS function for %s with magic=%d", methodNameUtf.c_str(), bindingId); + if (JS_IsException(function)) { + JS_FreeValue(context.context, objectValue); + std::lock_guard lock(context.mutex); + auto iterator = context.bindings.find(bindingId); + if (iterator != context.bindings.end()) { + MethodBinding* stored = iterator->second.get(); + env->DeleteGlobalRef(stored->method); + env->DeleteGlobalRef(stored->instance); + context.bindings.erase(iterator); + } + throwQuickJsException(env, context.refs, "Failed to create native function for " + objectNameUtf + "." + methodNameUtf); + return; + } + + if (JS_DefinePropertyValueStr(context.context, objectValue, methodNameUtf.c_str(), function, JS_PROP_CONFIGURABLE | JS_PROP_ENUMERABLE | JS_PROP_WRITABLE) < 0) { + JS_FreeValue(context.context, objectValue); + std::lock_guard lock(context.mutex); + auto iterator = context.bindings.find(bindingId); + if (iterator != context.bindings.end()) { + MethodBinding* stored = iterator->second.get(); + env->DeleteGlobalRef(stored->method); + env->DeleteGlobalRef(stored->instance); + context.bindings.erase(iterator); + } + throwQuickJsException(env, context.refs, "Failed to attach method " + objectNameUtf + "." + methodNameUtf); + return; + } + + JS_FreeValue(context.context, objectValue); +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredBridgeConfig.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredBridgeConfig.kt new file mode 100644 index 000000000..911ded55d --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredBridgeConfig.kt @@ -0,0 +1,22 @@ +package io.ton.walletkit.data.model + +/** + * Bridge configuration that can be persisted. + * + * @property network Selected network (mainnet/testnet) + * @property tonClientEndpoint TON client API endpoint + * @property tonApiUrl TonAPI endpoint + * @property apiKey API key (if used) - SENSITIVE + * @property bridgeUrl TonConnect bridge URL + * @property bridgeName Bridge identifier + * @suppress Internal storage model. Not part of public API. + */ +internal data class StoredBridgeConfig( + val network: String, + val tonClientEndpoint: String?, + val tonApiUrl: String?, + // SENSITIVE - should be encrypted + val apiKey: String?, + val bridgeUrl: String?, + val bridgeName: String?, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredSessionData.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredSessionData.kt new file mode 100644 index 000000000..e9a2774b4 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredSessionData.kt @@ -0,0 +1,31 @@ +package io.ton.walletkit.data.model + +/** + * Complete session data including private keys for TonConnect sessions. + * Based on TypeScript SessionData interface. + * + * @property sessionId Unique session identifier + * @property walletAddress Associated wallet address + * @property createdAt ISO 8601 timestamp of session creation + * @property lastActivityAt ISO 8601 timestamp of last activity + * @property privateKey Session private key (hex string) - CRITICAL security data + * @property publicKey Session public key (hex string) + * @property dAppName Display name of the dApp + * @property dAppDescription Description of the dApp + * @property domain Domain of the dApp + * @property dAppIconUrl Icon URL of the dApp + * @suppress Internal storage model. Not part of public API. + */ +internal data class StoredSessionData( + val sessionId: String, + val walletAddress: String, + val createdAt: String, + val lastActivityAt: String, + // CRITICAL - must be encrypted + val privateKey: String, + val publicKey: String, + val dAppName: String, + val dAppDescription: String, + val domain: String, + val dAppIconUrl: String, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredSessionHint.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredSessionHint.kt new file mode 100644 index 000000000..e7992409c --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredSessionHint.kt @@ -0,0 +1,19 @@ +package io.ton.walletkit.data.model + +/** + * Lightweight session metadata for quick lookups and UI display. + * + * This model stores minimal information about a session that can be used + * to display session cards or lists without loading full session data. + * It's particularly useful for showing dApp icons and URLs in session lists. + * + * @property manifestUrl URL to the dApp's TON Connect manifest + * @property dAppUrl Main URL of the dApp's website + * @property iconUrl URL to the dApp's icon/logo for UI display + * @suppress Internal storage model. Not part of public API. + */ +internal data class StoredSessionHint( + val manifestUrl: String?, + val dAppUrl: String?, + val iconUrl: String?, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredUserPreferences.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredUserPreferences.kt new file mode 100644 index 000000000..dcf50caa9 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredUserPreferences.kt @@ -0,0 +1,13 @@ +package io.ton.walletkit.data.model + +/** + * User preferences that should be persisted. + * + * @property activeWalletAddress Currently selected wallet address + * @property lastSelectedNetwork Last selected network (testnet/mainnet) + * @suppress Internal storage model. Not part of public API. + */ +internal data class StoredUserPreferences( + val activeWalletAddress: String?, + val lastSelectedNetwork: String?, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredWalletRecord.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredWalletRecord.kt new file mode 100644 index 000000000..6fc669def --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/model/StoredWalletRecord.kt @@ -0,0 +1,24 @@ +package io.ton.walletkit.data.model + +/** + * Persistent storage model for wallet data. + * + * This model is used internally by the storage layer to persist wallet information + * between app sessions. It contains sensitive data and should only be stored using + * encrypted storage mechanisms. + * + * **Security Note:** The mnemonic phrase is the master key to the wallet and must + * be handled with extreme care. It should never be logged or transmitted. + * + * @property mnemonic The wallet's recovery phrase (12/24 words) - **highly sensitive** + * @property name Optional user-defined name for the wallet (e.g., "Main Wallet") + * @property network Optional network identifier (e.g., "mainnet", "testnet") + * @property version Optional wallet contract version (e.g., "v4R2") + * @suppress Internal storage model. Not part of public API. + */ +internal data class StoredWalletRecord( + val mnemonic: List, + val name: String?, + val network: String?, + val version: String?, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/WalletKitStorage.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/WalletKitStorage.kt new file mode 100644 index 000000000..c53486f70 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/WalletKitStorage.kt @@ -0,0 +1,47 @@ +package io.ton.walletkit.data.storage + +import io.ton.walletkit.data.model.StoredBridgeConfig +import io.ton.walletkit.data.model.StoredSessionData +import io.ton.walletkit.data.model.StoredUserPreferences +import io.ton.walletkit.data.model.StoredWalletRecord + +/** + * Internal storage interface for WalletKit data persistence. + * + * @suppress This is an internal interface. Partners should not implement or use this directly. + */ +internal interface WalletKitStorage { + // Wallet management + suspend fun saveWallet(accountId: String, record: StoredWalletRecord) + + suspend fun loadWallet(accountId: String): StoredWalletRecord? + + suspend fun loadAllWallets(): Map + + suspend fun clear(accountId: String) + + // Session data (with private keys) + suspend fun saveSessionData(sessionId: String, session: StoredSessionData) + + suspend fun loadSessionData(sessionId: String): StoredSessionData? + + suspend fun loadAllSessionData(): Map + + suspend fun clearSessionData(sessionId: String) + + suspend fun updateSessionActivity(sessionId: String, timestamp: String) + + // Bridge configuration + suspend fun saveBridgeConfig(config: StoredBridgeConfig) + + suspend fun loadBridgeConfig(): StoredBridgeConfig? + + suspend fun clearBridgeConfig() + + // User preferences + suspend fun saveUserPreferences(prefs: StoredUserPreferences) + + suspend fun loadUserPreferences(): StoredUserPreferences? + + suspend fun clearUserPreferences() +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/bridge/BridgeStorageAdapter.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/bridge/BridgeStorageAdapter.kt new file mode 100644 index 000000000..a8f2ace80 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/bridge/BridgeStorageAdapter.kt @@ -0,0 +1,43 @@ +package io.ton.walletkit.data.storage.bridge + +/** + * Storage adapter interface for the WalletKit bridge to persist data between app restarts. + * This enables the JavaScript bundle to use Android secure storage instead of ephemeral + * WebView LocalStorage or memory storage. + * + * The bridge calls these methods via JavascriptInterface to persist: + * - Wallet metadata (addresses, public keys, versions) + * - Session data (session IDs, dApp info, private keys) + * - User preferences (active wallet, network settings) + * + * Implementations should use secure storage (e.g., EncryptedSharedPreferences) for + * sensitive data like session private keys. + * + * @suppress This is an internal interface. Partners should not implement or use this directly. + */ +internal interface BridgeStorageAdapter { + /** + * Get a value from storage by key. + * @param key The storage key + * @return The stored value as a JSON string, or null if not found + */ + suspend fun get(key: String): String? + + /** + * Set a value in storage. + * @param key The storage key + * @param value The value to store as a JSON string + */ + suspend fun set(key: String, value: String) + + /** + * Remove a value from storage. + * @param key The storage key to remove + */ + suspend fun remove(key: String) + + /** + * Clear all storage data. + */ + suspend fun clear() +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/bridge/SecureBridgeStorageAdapter.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/bridge/SecureBridgeStorageAdapter.kt new file mode 100644 index 000000000..286213295 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/bridge/SecureBridgeStorageAdapter.kt @@ -0,0 +1,88 @@ +package io.ton.walletkit.data.storage.bridge + +import android.content.Context +import android.util.Log +import io.ton.walletkit.data.storage.impl.SecureWalletKitStorage +import io.ton.walletkit.domain.constants.LogConstants +import io.ton.walletkit.domain.constants.StorageConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Secure implementation of BridgeStorageAdapter using EncryptedSharedPreferences. + * + * This adapter provides secure persistent storage for the WalletKit JavaScript bridge, + * replacing ephemeral WebView LocalStorage with Android's encrypted storage. + * + * Security: + * - Uses EncryptedSharedPreferences with AES256-GCM encryption + * - Hardware-backed master key when available + * - All keys prefixed with "bridge:" for isolation + * + * Thread Safety: + * - All operations are thread-safe via coroutine dispatchers + * - Safe for concurrent access from multiple threads + * + * @suppress This is an internal implementation class. + */ +internal class SecureBridgeStorageAdapter( + context: Context, +) : BridgeStorageAdapter { + private val appContext = context.applicationContext + private val secureStorage = SecureWalletKitStorage(appContext, StorageConstants.BRIDGE_STORAGE_NAME) + + override suspend fun get(key: String): String? = withContext(Dispatchers.IO) { + try { + secureStorage.getRawValue(bridgeKey(key)) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_GET_RAW_VALUE + key, e) + null + } + } + + override suspend fun set( + key: String, + value: String, + ) { + withContext(Dispatchers.IO) { + try { + secureStorage.setRawValue(bridgeKey(key), value) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_SET_RAW_VALUE + key, e) + throw e + } + } + } + + override suspend fun remove(key: String) { + withContext(Dispatchers.IO) { + try { + secureStorage.removeRawValue(bridgeKey(key)) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_REMOVE_RAW_VALUE + key, e) + } + } + } + + override suspend fun clear() { + withContext(Dispatchers.IO) { + try { + secureStorage.clearBridgeData() + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_BRIDGE_DATA, e) + } + } + } + + private fun bridgeKey(key: String) = StorageConstants.KEY_PREFIX_BRIDGE + key + + companion object { + private const val TAG = LogConstants.TAG_BRIDGE_STORAGE + + // Storage Errors + const val ERROR_FAILED_GET_RAW_VALUE = "Failed to get raw value: " + const val ERROR_FAILED_SET_RAW_VALUE = "Failed to set raw value: " + const val ERROR_FAILED_REMOVE_RAW_VALUE = "Failed to remove raw value: " + const val ERROR_FAILED_CLEAR_BRIDGE_DATA = "Failed to clear bridge data" + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/encryption/CryptoManager.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/encryption/CryptoManager.kt new file mode 100644 index 000000000..96c520414 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/encryption/CryptoManager.kt @@ -0,0 +1,270 @@ +package io.ton.walletkit.data.storage.encryption + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import io.ton.walletkit.domain.constants.CryptoConstants +import java.nio.ByteBuffer +import java.security.KeyStore +import java.util.Arrays +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Manages encryption and decryption of sensitive data using Android Keystore. + * Uses AES-256-GCM encryption for security and integrity. + * + * Key Features: + * - Hardware-backed encryption when available (StrongBox on supported devices) + * - AES-256-GCM for authenticated encryption + * - Unique IV for each encryption operation + * - Secure memory clearing after use + * + * @suppress This is an internal implementation class. + */ +internal class CryptoManager( + private val keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, +) { + private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + + init { + // Ensure encryption key exists + if (!keyStore.containsAlias(keystoreAlias)) { + generateKey() + } + } + + /** + * Encrypts the given plaintext data. + * + * @param plaintext The data to encrypt + * @return Encrypted data with IV prepended (IV_SIZE bytes + ciphertext) + * @throws SecurityException if encryption fails + */ + fun encrypt(plaintext: String): ByteArray = try { + val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8) + val result = encrypt(plaintextBytes) + // Clear plaintext from memory + Arrays.fill(plaintextBytes, 0.toByte()) + result + } catch (e: Exception) { + Log.e(TAG, ERROR_ENCRYPTION_FAILED, e) + throw SecurityException(ERROR_FAILED_ENCRYPT_DATA, e) + } + + /** + * Encrypts the given plaintext bytes. + * + * @param plaintext The bytes to encrypt + * @return Encrypted data with IV prepended (IV_SIZE bytes + ciphertext) + */ + fun encrypt(plaintext: ByteArray): ByteArray { + try { + val cipher = getCipher() + val secretKey = getSecretKey() + + // Let AndroidKeyStore generate a fresh IV to satisfy randomized encryption requirement + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv ?: throw IllegalStateException(ERROR_CIPHER_NO_IV) + if (iv.size != CryptoConstants.GCM_IV_SIZE) { + throw IllegalStateException(ERROR_UNEXPECTED_IV_SIZE + iv.size) + } + + // Encrypt the data + val ciphertext = cipher.doFinal(plaintext) + + // Combine IV + ciphertext + return ByteBuffer.allocate(GCM_IV_SIZE + ciphertext.size) + .put(iv) + .put(ciphertext) + .array() + } catch (e: Exception) { + Log.e(TAG, ERROR_ENCRYPTION_FAILED, e) + throw SecurityException(ERROR_FAILED_ENCRYPT_DATA, e) + } + } + + /** + * Decrypts the given encrypted data. + * + * @param encryptedData The encrypted data with IV prepended + * @return Decrypted plaintext string + * @throws SecurityException if decryption fails + */ + fun decrypt(encryptedData: ByteArray): String = try { + val plaintextBytes = decryptToBytes(encryptedData) + val result = String(plaintextBytes, Charsets.UTF_8) + // Clear plaintext from memory + Arrays.fill(plaintextBytes, 0.toByte()) + result + } catch (e: Exception) { + Log.e(TAG, ERROR_DECRYPTION_FAILED, e) + throw SecurityException(ERROR_FAILED_DECRYPT_DATA, e) + } + + /** + * Decrypts the given encrypted data to bytes. + * + * @param encryptedData The encrypted data with IV prepended + * @return Decrypted plaintext bytes + */ + fun decryptToBytes(encryptedData: ByteArray): ByteArray { + try { + if (encryptedData.size < GCM_IV_SIZE) { + throw IllegalArgumentException(ERROR_ENCRYPTED_DATA_TOO_SHORT) + } + + val cipher = getCipher() + val secretKey = getSecretKey() + + // Extract IV and ciphertext + val buffer = ByteBuffer.wrap(encryptedData) + val iv = ByteArray(GCM_IV_SIZE) + buffer.get(iv) + val ciphertext = ByteArray(buffer.remaining()) + buffer.get(ciphertext) + + // Initialize cipher for decryption + val spec = GCMParameterSpec(GCM_TAG_SIZE, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + + // Decrypt the data + return cipher.doFinal(ciphertext) + } catch (e: Exception) { + Log.e(TAG, ERROR_DECRYPTION_FAILED, e) + throw SecurityException(ERROR_FAILED_DECRYPT_DATA, e) + } + } + + /** + * Deletes the encryption key from the keystore. + * WARNING: This will make all encrypted data unrecoverable! + */ + fun deleteKey() { + try { + keyStore.deleteEntry(keystoreAlias) + Log.d(TAG, ERROR_ENCRYPTION_KEY_DELETED) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_DELETE_ENCRYPTION_KEY, e) + } + } + + /** + * Checks if the encryption key exists in the keystore. + */ + fun hasKey(): Boolean = keyStore.containsAlias(keystoreAlias) + + /** + * Generates a new AES-256 encryption key in the Android Keystore. + * Uses hardware-backed storage when available (StrongBox). + */ + private fun generateKey() { + try { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE, + ) + + val builder = KeyGenParameterSpec.Builder( + keystoreAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(AES_KEY_SIZE) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(false) // Can be enabled for additional security + + // Try to use StrongBox (hardware-backed) if available (Android 9+) + var strongBoxSuccess = false + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + try { + val strongBoxBuilder = KeyGenParameterSpec.Builder( + keystoreAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setIsStrongBoxBacked(true) // Request StrongBox + + keyGenerator.init(strongBoxBuilder.build()) + keyGenerator.generateKey() + strongBoxSuccess = true + Log.d(TAG, ERROR_GENERATED_STRONGBOX_KEY) + } catch (e: Exception) { + // StrongBox not available, fall back to regular keystore + Log.d(TAG, ERROR_STRONGBOX_NOT_AVAILABLE, e) + } + } + + // Fall back to regular keystore if StrongBox failed or not attempted + if (!strongBoxSuccess) { + keyGenerator.init(builder.build()) // Use original builder without StrongBox + keyGenerator.generateKey() + Log.d(TAG, ERROR_GENERATED_ENCRYPTION_KEY) + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_GENERATE_ENCRYPTION_KEY, e) + throw SecurityException(ERROR_FAILED_GENERATE_ENCRYPTION_KEY, e) + } + } + + private fun getSecretKey(): SecretKey { + val entry = keyStore.getEntry(keystoreAlias, null) + if (entry !is KeyStore.SecretKeyEntry) { + throw IllegalStateException(ERROR_KEYSTORE_NOT_SECRET_KEY_ENTRY) + } + return entry.secretKey + } + + private fun getCipher(): Cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + + companion object { + private const val TAG = CryptoConstants.TAG_CRYPTO_MANAGER + private const val ANDROID_KEYSTORE = CryptoConstants.ANDROID_KEYSTORE + private const val DEFAULT_KEYSTORE_ALIAS = CryptoConstants.DEFAULT_KEYSTORE_ALIAS + private const val CIPHER_TRANSFORMATION = CryptoConstants.CIPHER_TRANSFORMATION + private const val AES_KEY_SIZE = CryptoConstants.AES_KEY_SIZE + private const val GCM_IV_SIZE = CryptoConstants.GCM_IV_SIZE // 96 bits recommended for GCM + private const val GCM_TAG_SIZE = 128 // 128 bits authentication tag + + /** + * Securely clears sensitive data from memory. + * This helps prevent sensitive data from being recovered from memory dumps. + */ + fun secureClear(data: CharArray) { + Arrays.fill(data, '\u0000') + } + + fun secureClear(data: ByteArray) { + Arrays.fill(data, 0.toByte()) + } + + fun secureClear(data: String?): CharArray { + val chars = data?.toCharArray() ?: CharArray(0) + secureClear(chars) + return chars + } + + // Encryption/Decryption Errors + const val ERROR_ENCRYPTION_FAILED = "Encryption failed" + const val ERROR_FAILED_ENCRYPT_DATA = "Failed to encrypt data" + const val ERROR_DECRYPTION_FAILED = "Decryption failed" + const val ERROR_FAILED_DECRYPT_DATA = "Failed to decrypt data" + const val ERROR_CIPHER_NO_IV = "Cipher did not provide an IV" + const val ERROR_UNEXPECTED_IV_SIZE = "Unexpected IV size: " + const val ERROR_ENCRYPTED_DATA_TOO_SHORT = "Encrypted data too short" + const val ERROR_ENCRYPTION_KEY_DELETED = "Encryption key deleted from keystore" + const val ERROR_FAILED_DELETE_ENCRYPTION_KEY = "Failed to delete encryption key" + const val ERROR_GENERATED_STRONGBOX_KEY = "Generated StrongBox-backed encryption key" + const val ERROR_STRONGBOX_NOT_AVAILABLE = "StrongBox not available, using regular keystore" + const val ERROR_GENERATED_ENCRYPTION_KEY = "Generated encryption key in Android Keystore" + const val ERROR_FAILED_GENERATE_ENCRYPTION_KEY = "Failed to generate encryption key" + const val ERROR_KEYSTORE_NOT_SECRET_KEY_ENTRY = "KeyStore entry is not a SecretKeyEntry" + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/impl/SecureWalletKitStorage.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/impl/SecureWalletKitStorage.kt new file mode 100644 index 000000000..fe7e365a6 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/impl/SecureWalletKitStorage.kt @@ -0,0 +1,725 @@ +package io.ton.walletkit.data.storage.impl + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import io.ton.walletkit.data.model.StoredBridgeConfig +import io.ton.walletkit.data.model.StoredSessionData +import io.ton.walletkit.data.model.StoredUserPreferences +import io.ton.walletkit.data.model.StoredWalletRecord +import io.ton.walletkit.data.storage.WalletKitStorage +import io.ton.walletkit.data.storage.encryption.CryptoManager +import io.ton.walletkit.domain.constants.JsonConstants +import io.ton.walletkit.domain.constants.LogConstants +import io.ton.walletkit.domain.constants.MiscConstants +import io.ton.walletkit.domain.constants.ResponseConstants +import io.ton.walletkit.domain.constants.StorageConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** + * Secure implementation of WalletKitStorage using Android Keystore and EncryptedSharedPreferences. + * + * Security Features: + * - Mnemonic phrases encrypted with AES-256-GCM using Android Keystore + * - Hardware-backed encryption when available (StrongBox) + * - EncryptedSharedPreferences for metadata + * - Secure memory clearing after sensitive operations + * - Production-ready implementation + * + * Data Protection: + * - Wallet mnemonics: Double-encrypted (EncryptedSharedPreferences + CryptoManager) + * - Session hints: EncryptedSharedPreferences + * - Wallet metadata: EncryptedSharedPreferences + * + * @param context Application context + * @param sharedPrefsName Name for the encrypted SharedPreferences file + * @suppress This is an internal implementation class. + */ +internal class SecureWalletKitStorage( + context: Context, + sharedPrefsName: String = StorageConstants.DEFAULT_SECURE_STORAGE_NAME, +) : WalletKitStorage { + private val appContext = context.applicationContext + + // Master key for EncryptedSharedPreferences + private val masterKey: MasterKey by lazy { + try { + MasterKey.Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CREATE_MASTER_KEY, e) + throw SecurityException(ERROR_FAILED_INIT_SECURE_STORAGE, e) + } + } + + // EncryptedSharedPreferences for storing wallet data + private val encryptedPrefs by lazy { + try { + EncryptedSharedPreferences.create( + appContext, + sharedPrefsName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CREATE_ENCRYPTED_PREFS, e) + throw SecurityException(ERROR_FAILED_INIT_SECURE_STORAGE, e) + } + } + + // Additional encryption layer for mnemonics using Android Keystore directly + private val cryptoManager by lazy { + CryptoManager(StorageConstants.MNEMONIC_KEYSTORE_KEY) + } + + private fun walletKey(accountId: String) = StorageConstants.KEY_PREFIX_WALLET + accountId + + override suspend fun saveWallet( + accountId: String, + record: StoredWalletRecord, + ) { + withContext(Dispatchers.IO) { + try { + // Double-encrypt the mnemonic for maximum security + val encryptedMnemonic = encryptMnemonic(record.mnemonic) + + // Create a record with encrypted mnemonic + val secureRecord = + JSONObject().apply { + put(JsonConstants.KEY_MNEMONIC, encryptedMnemonic) // Base64 of encrypted bytes + record.name?.let { put(JsonConstants.KEY_NAME, it) } + record.network?.let { put(JsonConstants.KEY_NETWORK, it) } + record.version?.let { put(JsonConstants.KEY_VERSION, it) } + } + + // Store in EncryptedSharedPreferences (second layer of encryption) + encryptedPrefs.edit { + putString(walletKey(accountId), secureRecord.toString()) + } + + Log.d(TAG, ERROR_WALLET_SAVED_SECURELY + accountId) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_SAVE_WALLET + accountId, e) + throw SecurityException(ERROR_FAILED_SAVE_WALLET_SECURELY, e) + } + } + } + + override suspend fun loadWallet(accountId: String): StoredWalletRecord? { + return withContext(Dispatchers.IO) { + try { + val encrypted = + encryptedPrefs.getString(walletKey(accountId), null) + ?: encryptedPrefs.getString(accountId, null) // Fallback for legacy keys + ?: return@withContext null + + val json = JSONObject(encrypted) + + // Decrypt the mnemonic + val encryptedMnemonic = json.optString(JsonConstants.KEY_MNEMONIC) + if (encryptedMnemonic.isBlank()) { + Log.e(TAG, ERROR_MISSING_MNEMONIC + accountId) + return@withContext null + } + + val mnemonic = decryptMnemonic(encryptedMnemonic) + + StoredWalletRecord( + mnemonic = mnemonic, + name = json.optString(JsonConstants.KEY_NAME).takeIf { it.isNotBlank() }, + network = json.optString(JsonConstants.KEY_NETWORK).takeIf { it.isNotBlank() }, + version = json.optString(JsonConstants.KEY_VERSION).takeIf { it.isNotBlank() }, + ) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_LOAD_WALLET + accountId, e) + null + } + } + } + + override suspend fun loadAllWallets(): Map { + return withContext(Dispatchers.IO) { + try { + encryptedPrefs.all + .mapNotNull { (key, _) -> + if (!key.startsWith(StorageConstants.KEY_PREFIX_WALLET)) return@mapNotNull null + val accountId = key.removePrefix(StorageConstants.KEY_PREFIX_WALLET) + val record = loadWallet(accountId) + if (record != null) accountId to record else null + } + .toMap() + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_LOAD_ALL_WALLETS, e) + emptyMap() + } + } + } + + override suspend fun clear(accountId: String) { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + remove(walletKey(accountId)) + remove(accountId) // Remove legacy key if exists + } + Log.d(TAG, ERROR_WALLET_CLEARED + accountId) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_WALLET + accountId, e) + } + } + } + + // ===== Session Data Methods (with private keys) ===== + + private fun sessionKey(sessionId: String) = StorageConstants.KEY_PREFIX_SESSION + sessionId + + override suspend fun saveSessionData( + sessionId: String, + session: StoredSessionData, + ) { + withContext(Dispatchers.IO) { + try { + // Encrypt the private key (CRITICAL security data) + val encryptedPrivateKey = encryptSessionPrivateKey(session.privateKey) + + // Create JSON with encrypted private key + val sessionJson = + JSONObject().apply { + put(ResponseConstants.KEY_SESSION_ID, session.sessionId) + put(ResponseConstants.KEY_WALLET_ADDRESS, session.walletAddress) + put(ResponseConstants.KEY_CREATED_AT, session.createdAt) + put(ResponseConstants.KEY_LAST_ACTIVITY_AT, session.lastActivityAt) + put(ResponseConstants.KEY_PRIVATE_KEY, encryptedPrivateKey) // Encrypted + put(ResponseConstants.KEY_PUBLIC_KEY, session.publicKey) + put(ResponseConstants.KEY_DAPP_NAME, session.dAppName) + put(ResponseConstants.KEY_DAPP_DESCRIPTION, session.dAppDescription) + put(ResponseConstants.KEY_DOMAIN, sanitizeUrl(session.domain)) + put(ResponseConstants.KEY_DAPP_ICON_URL, sanitizeUrl(session.dAppIconUrl)) + } + + // Store in EncryptedSharedPreferences + encryptedPrefs.edit { + putString(sessionKey(sessionId), sessionJson.toString()) + } + + Log.d( + TAG, + ERROR_SESSION_DATA_SAVED + sessionId + ERROR_SESSION_FOR_DOMAIN + sanitizeUrl(session.domain), + ) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_SAVE_SESSION_DATA + sessionId, e) + throw e + } + } + } + + override suspend fun loadSessionData(sessionId: String): StoredSessionData? = withContext(Dispatchers.IO) { + try { + val jsonString = encryptedPrefs.getString(sessionKey(sessionId), null) + if (jsonString != null) { + val json = JSONObject(jsonString) + val encryptedPrivateKey = json.getString(ResponseConstants.KEY_PRIVATE_KEY) + val decryptedPrivateKey = decryptSessionPrivateKey(encryptedPrivateKey) + + StoredSessionData( + sessionId = json.getString(ResponseConstants.KEY_SESSION_ID), + walletAddress = json.getString(ResponseConstants.KEY_WALLET_ADDRESS), + createdAt = json.getString(ResponseConstants.KEY_CREATED_AT), + lastActivityAt = json.getString(ResponseConstants.KEY_LAST_ACTIVITY_AT), + privateKey = decryptedPrivateKey, + publicKey = json.getString(ResponseConstants.KEY_PUBLIC_KEY), + dAppName = json.getString(ResponseConstants.KEY_DAPP_NAME), + dAppDescription = json.getString(ResponseConstants.KEY_DAPP_DESCRIPTION), + domain = json.getString(ResponseConstants.KEY_DOMAIN), + dAppIconUrl = json.getString(ResponseConstants.KEY_DAPP_ICON_URL), + ) + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_LOAD_SESSION_DATA + sessionId, e) + null + } + } + + override suspend fun loadAllSessionData(): Map = withContext(Dispatchers.IO) { + try { + encryptedPrefs.all + .mapNotNull { (key, value) -> + if (!key.startsWith(StorageConstants.KEY_PREFIX_SESSION)) return@mapNotNull null + val sessionId = key.removePrefix(StorageConstants.KEY_PREFIX_SESSION) + val sessionData = (value as? String)?.let { jsonString -> + try { + val json = JSONObject(jsonString) + val encryptedPrivateKey = json.getString(ResponseConstants.KEY_PRIVATE_KEY) + val decryptedPrivateKey = decryptSessionPrivateKey(encryptedPrivateKey) + + StoredSessionData( + sessionId = json.getString(ResponseConstants.KEY_SESSION_ID), + walletAddress = json.getString(ResponseConstants.KEY_WALLET_ADDRESS), + createdAt = json.getString(ResponseConstants.KEY_CREATED_AT), + lastActivityAt = json.getString(ResponseConstants.KEY_LAST_ACTIVITY_AT), + privateKey = decryptedPrivateKey, + publicKey = json.getString(ResponseConstants.KEY_PUBLIC_KEY), + dAppName = json.getString(ResponseConstants.KEY_DAPP_NAME), + dAppDescription = json.getString(ResponseConstants.KEY_DAPP_DESCRIPTION), + domain = json.getString(ResponseConstants.KEY_DOMAIN), + dAppIconUrl = json.getString(ResponseConstants.KEY_DAPP_ICON_URL), + ) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_PARSE_SESSION_DATA + sessionId, e) + null + } + } + if (sessionData != null) sessionId to sessionData else null + } + .toMap() + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_LOAD_ALL_SESSION_DATA, e) + emptyMap() + } + } + + override suspend fun clearSessionData(sessionId: String) { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + remove(sessionKey(sessionId)) + } + Log.d(TAG, ERROR_SESSION_DATA_CLEARED + sessionId) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_SESSION_DATA + sessionId, e) + } + } + } + + override suspend fun updateSessionActivity( + sessionId: String, + timestamp: String, + ) { + withContext(Dispatchers.IO) { + try { + // Load existing session + val session = loadSessionData(sessionId) + if (session != null) { + // Update lastActivityAt + val updatedSession = session.copy(lastActivityAt = timestamp) + saveSessionData(sessionId, updatedSession) + Log.d(TAG, ERROR_SESSION_ACTIVITY_UPDATED + sessionId) + } else { + Log.w(TAG, ERROR_CANNOT_UPDATE_NON_EXISTENT_SESSION + sessionId) + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_UPDATE_SESSION_ACTIVITY + sessionId, e) + } + } + } + + // ===== Bridge Configuration Methods ===== + + private val bridgeConfigKey = StorageConstants.BRIDGE_CONFIG_KEY + + override suspend fun saveBridgeConfig(config: StoredBridgeConfig) { + withContext(Dispatchers.IO) { + try { + // Encrypt API key if present + val encryptedApiKey = config.apiKey?.let { encryptApiKey(it) } + + val configJson = + JSONObject().apply { + put(JsonConstants.KEY_NETWORK, config.network) + config.tonClientEndpoint?.let { put(ResponseConstants.KEY_TON_CLIENT_ENDPOINT, sanitizeUrl(it)) } + config.tonApiUrl?.let { put(JsonConstants.KEY_TON_API_URL, sanitizeUrl(it)) } + encryptedApiKey?.let { put(ResponseConstants.KEY_API_KEY, it) } + config.bridgeUrl?.let { put(JsonConstants.KEY_BRIDGE_URL, sanitizeUrl(it)) } + config.bridgeName?.let { put(JsonConstants.KEY_BRIDGE_NAME, it) } + } + + encryptedPrefs.edit { + putString(bridgeConfigKey, configJson.toString()) + } + Log.d(TAG, ERROR_BRIDGE_CONFIG_SAVED + config.network) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_SAVE_BRIDGE_CONFIG, e) + throw e + } + } + } + + override suspend fun loadBridgeConfig(): StoredBridgeConfig? = withContext(Dispatchers.IO) { + try { + val jsonString = encryptedPrefs.getString(bridgeConfigKey, null) + if (jsonString != null) { + val json = JSONObject(jsonString) + val encryptedApiKey = json.optString(ResponseConstants.KEY_API_KEY, null) + val decryptedApiKey = encryptedApiKey?.let { decryptApiKey(it) } + + StoredBridgeConfig( + network = json.getString(JsonConstants.KEY_NETWORK), + tonClientEndpoint = json.optString(ResponseConstants.KEY_TON_CLIENT_ENDPOINT, null), + tonApiUrl = json.optString(JsonConstants.KEY_TON_API_URL, null), + apiKey = decryptedApiKey, + bridgeUrl = json.optString(JsonConstants.KEY_BRIDGE_URL, null), + bridgeName = json.optString(JsonConstants.KEY_BRIDGE_NAME, null), + ) + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_LOAD_BRIDGE_CONFIG, e) + null + } + } + + override suspend fun clearBridgeConfig() { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + remove(bridgeConfigKey) + } + Log.d(TAG, ERROR_BRIDGE_CONFIG_CLEARED) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_BRIDGE_CONFIG, e) + } + } + } + + // ===== User Preferences Methods ===== + + private val userPreferencesKey = StorageConstants.USER_PREFERENCES_KEY + + override suspend fun saveUserPreferences(prefs: StoredUserPreferences) { + withContext(Dispatchers.IO) { + try { + val prefsJson = + JSONObject().apply { + prefs.activeWalletAddress?.let { put(ResponseConstants.KEY_ACTIVE_WALLET_ADDRESS, it) } + prefs.lastSelectedNetwork?.let { put(ResponseConstants.KEY_LAST_SELECTED_NETWORK, it) } + } + + encryptedPrefs.edit { + putString(userPreferencesKey, prefsJson.toString()) + } + Log.d(TAG, ERROR_USER_PREFERENCES_SAVED) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_SAVE_USER_PREFERENCES, e) + throw e + } + } + } + + override suspend fun loadUserPreferences(): StoredUserPreferences? = withContext(Dispatchers.IO) { + try { + val jsonString = encryptedPrefs.getString(userPreferencesKey, null) + if (jsonString != null) { + val json = JSONObject(jsonString) + StoredUserPreferences( + activeWalletAddress = json.optString(ResponseConstants.KEY_ACTIVE_WALLET_ADDRESS, null), + lastSelectedNetwork = json.optString(ResponseConstants.KEY_LAST_SELECTED_NETWORK, null), + ) + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_LOAD_USER_PREFERENCES, e) + null + } + } + + override suspend fun clearUserPreferences() { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + remove(userPreferencesKey) + } + Log.d(TAG, ERROR_USER_PREFERENCES_CLEARED) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_USER_PREFERENCES, e) + } + } + } + + /** + * Clears all stored data. Use with caution! + */ + suspend fun clearAll() { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + clear() + } + Log.d(TAG, ERROR_ALL_DATA_CLEARED) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_ALL_DATA, e) + } + } + } + + /** + * Encrypts a mnemonic phrase list using Android Keystore. + * Returns Base64-encoded encrypted data. + */ + private fun encryptMnemonic(mnemonic: List): String { + try { + // Join mnemonic words with space + val mnemonicString = mnemonic.joinToString(MiscConstants.SPACE_DELIMITER) + + // Encrypt using CryptoManager + val encryptedBytes = cryptoManager.encrypt(mnemonicString) + + // Encode to Base64 for storage + return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_ENCRYPT_MNEMONIC, e) + throw SecurityException(ERROR_FAILED_ENCRYPT_MNEMONIC, e) + } + } + + /** + * Decrypts a Base64-encoded encrypted mnemonic. + * Returns the mnemonic as a list of words. + */ + private fun decryptMnemonic(encryptedBase64: String): List { + try { + // Decode from Base64 + val encryptedBytes = Base64.decode(encryptedBase64, Base64.NO_WRAP) + + // Decrypt using CryptoManager + val mnemonicString = cryptoManager.decrypt(encryptedBytes) + + // Split into words + val words = mnemonicString.split(MiscConstants.SPACE_DELIMITER).filter { it.isNotBlank() } + + // Validate mnemonic word count (typically 12 or 24 words) + if (words.size !in VALID_MNEMONIC_SIZES) { + Log.w(TAG, ERROR_UNUSUAL_MNEMONIC_WORD_COUNT + words.size) + } + + return words + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_DECRYPT_MNEMONIC, e) + throw SecurityException(ERROR_FAILED_DECRYPT_MNEMONIC, e) + } + } + + /** + * Sanitizes URLs to prevent injection attacks. + * Only allows http:// and https:// schemes. + */ + private fun sanitizeUrl(url: String): String { + val trimmed = url.trim() + return when { + trimmed.startsWith(MiscConstants.SCHEME_HTTPS, ignoreCase = true) || + trimmed.startsWith(MiscConstants.SCHEME_HTTP, ignoreCase = true) -> trimmed + else -> { + Log.w(TAG, ERROR_INVALID_URL_SCHEME_REJECTING + trimmed) + MiscConstants.EMPTY_STRING + } + } + } + + /** + * Encrypts a session private key using a separate encryption key. + * Returns Base64-encoded encrypted data. + */ + private fun encryptSessionPrivateKey(privateKey: String): String { + try { + val sessionCryptoManager = CryptoManager(StorageConstants.SESSION_KEYSTORE_KEY) + val encryptedBytes = sessionCryptoManager.encrypt(privateKey) + return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_ENCRYPT_SESSION_PRIVATE_KEY, e) + throw SecurityException(ERROR_FAILED_ENCRYPT_SESSION_PRIVATE_KEY, e) + } + } + + /** + * Decrypts a session private key. + * Returns the decrypted private key string. + */ + private fun decryptSessionPrivateKey(encryptedBase64: String): String { + try { + val sessionCryptoManager = CryptoManager(StorageConstants.SESSION_KEYSTORE_KEY) + val encryptedBytes = Base64.decode(encryptedBase64, Base64.NO_WRAP) + return sessionCryptoManager.decrypt(encryptedBytes) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_DECRYPT_SESSION_PRIVATE_KEY, e) + throw SecurityException(ERROR_FAILED_DECRYPT_SESSION_PRIVATE_KEY, e) + } + } + + /** + * Encrypts an API key using a separate encryption key. + * Returns Base64-encoded encrypted data. + */ + private fun encryptApiKey(apiKey: String): String { + try { + val apiKeyCryptoManager = CryptoManager(StorageConstants.API_KEY_KEYSTORE_KEY) + val encryptedBytes = apiKeyCryptoManager.encrypt(apiKey) + return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_ENCRYPT_API_KEY, e) + throw SecurityException(ERROR_FAILED_ENCRYPT_API_KEY, e) + } + } + + /** + * Decrypts an API key. + * Returns the decrypted API key string. + */ + private fun decryptApiKey(encryptedBase64: String): String { + try { + val apiKeyCryptoManager = CryptoManager(StorageConstants.API_KEY_KEYSTORE_KEY) + val encryptedBytes = Base64.decode(encryptedBase64, Base64.NO_WRAP) + return apiKeyCryptoManager.decrypt(encryptedBytes) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_DECRYPT_API_KEY, e) + throw SecurityException(ERROR_FAILED_DECRYPT_API_KEY, e) + } + } + + // ========== Raw Key-Value Storage for Bridge ========== + + /** + * Get a raw value from encrypted storage (used by BridgeStorageAdapter). + * @param key The storage key + * @return The stored value, or null if not found + */ + suspend fun getRawValue(key: String): String? = withContext(Dispatchers.IO) { + try { + encryptedPrefs.getString(key, null) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_GET_RAW_VALUE + key, e) + null + } + } + + /** + * Set a raw value in encrypted storage (used by BridgeStorageAdapter). + * @param key The storage key + * @param value The value to store + */ + suspend fun setRawValue( + key: String, + value: String, + ) { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + putString(key, value) + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_SET_RAW_VALUE + key, e) + throw e + } + } + } + + /** + * Remove a raw value from encrypted storage (used by BridgeStorageAdapter). + * @param key The storage key to remove + */ + suspend fun removeRawValue(key: String) { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit { + remove(key) + } + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_REMOVE_RAW_VALUE + key, e) + } + } + } + + /** + * Clear all bridge-related data from storage (keys starting with "bridge:"). + */ + suspend fun clearBridgeData() { + withContext(Dispatchers.IO) { + try { + val bridgeKeys = encryptedPrefs.all.keys.filter { it.startsWith(StorageConstants.KEY_PREFIX_BRIDGE) } + encryptedPrefs.edit { + bridgeKeys.forEach { remove(it) } + } + Log.d(TAG, ERROR_CLEARED_BRIDGE_STORAGE_KEYS + bridgeKeys.size + MiscConstants.BRIDGE_STORAGE_KEYS_COUNT_SUFFIX) + } catch (e: Exception) { + Log.e(TAG, ERROR_FAILED_CLEAR_BRIDGE_DATA, e) + } + } + } + + companion object { + private const val TAG = LogConstants.TAG_SECURE_STORAGE + private val VALID_MNEMONIC_SIZES = setOf(12, 15, 18, 21, 24) // BIP39 standard + + // Security / Initialization Errors + const val ERROR_FAILED_INIT_SECURE_STORAGE = "Failed to initialize secure storage" + const val ERROR_FAILED_CREATE_MASTER_KEY = "Failed to create MasterKey" + const val ERROR_FAILED_CREATE_ENCRYPTED_PREFS = "Failed to create EncryptedSharedPreferences" + + // Wallet Operation Errors + const val ERROR_FAILED_SAVE_WALLET_SECURELY = "Failed to save wallet securely" + const val ERROR_FAILED_SAVE_WALLET = "Failed to save wallet: " + const val ERROR_WALLET_SAVED_SECURELY = "Wallet saved securely: " + const val ERROR_MISSING_MNEMONIC = "Missing mnemonic in stored wallet: " + const val ERROR_FAILED_LOAD_WALLET = "Failed to load wallet: " + const val ERROR_FAILED_LOAD_ALL_WALLETS = "Failed to load all wallets" + const val ERROR_WALLET_CLEARED = "Wallet cleared: " + const val ERROR_FAILED_CLEAR_WALLET = "Failed to clear wallet: " + + // Session Operation Errors + const val ERROR_SESSION_DATA_SAVED = "Session data saved: " + const val ERROR_FAILED_SAVE_SESSION_DATA = "Failed to save session data: " + const val ERROR_FAILED_LOAD_SESSION_DATA = "Failed to load session data: " + const val ERROR_FAILED_PARSE_SESSION_DATA = "Failed to parse session data: " + const val ERROR_FAILED_LOAD_ALL_SESSION_DATA = "Failed to load all session data" + const val ERROR_SESSION_DATA_CLEARED = "Session data cleared: " + const val ERROR_FAILED_CLEAR_SESSION_DATA = "Failed to clear session data: " + const val ERROR_SESSION_ACTIVITY_UPDATED = "Session activity updated: " + const val ERROR_CANNOT_UPDATE_NON_EXISTENT_SESSION = "Cannot update activity for non-existent session: " + const val ERROR_FAILED_UPDATE_SESSION_ACTIVITY = "Failed to update session activity: " + + // Config Operation Errors + const val ERROR_BRIDGE_CONFIG_SAVED = "Bridge config saved for network: " + const val ERROR_FAILED_SAVE_BRIDGE_CONFIG = "Failed to save bridge config" + const val ERROR_FAILED_LOAD_BRIDGE_CONFIG = "Failed to load bridge config" + const val ERROR_BRIDGE_CONFIG_CLEARED = "Bridge config cleared" + const val ERROR_FAILED_CLEAR_BRIDGE_CONFIG = "Failed to clear bridge config" + + // User Preferences Errors + const val ERROR_USER_PREFERENCES_SAVED = "User preferences saved" + const val ERROR_FAILED_SAVE_USER_PREFERENCES = "Failed to save user preferences" + const val ERROR_FAILED_LOAD_USER_PREFERENCES = "Failed to load user preferences" + const val ERROR_USER_PREFERENCES_CLEARED = "User preferences cleared" + const val ERROR_FAILED_CLEAR_USER_PREFERENCES = "Failed to clear user preferences" + + // General Storage Errors + const val ERROR_ALL_DATA_CLEARED = "All data cleared" + const val ERROR_FAILED_CLEAR_ALL_DATA = "Failed to clear all data" + const val ERROR_CLEARED_BRIDGE_STORAGE_KEYS = "Cleared " + const val ERROR_FAILED_CLEAR_BRIDGE_DATA = "Failed to clear bridge data" + const val ERROR_FAILED_GET_RAW_VALUE = "Failed to get raw value: " + const val ERROR_FAILED_SET_RAW_VALUE = "Failed to set raw value: " + const val ERROR_FAILED_REMOVE_RAW_VALUE = "Failed to remove raw value: " + + // Encryption/Decryption Errors + const val ERROR_FAILED_ENCRYPT_MNEMONIC = "Failed to encrypt mnemonic" + const val ERROR_UNUSUAL_MNEMONIC_WORD_COUNT = "Unusual mnemonic word count: " + const val ERROR_FAILED_DECRYPT_MNEMONIC = "Failed to decrypt mnemonic" + const val ERROR_FAILED_ENCRYPT_SESSION_PRIVATE_KEY = "Failed to encrypt session private key" + const val ERROR_FAILED_DECRYPT_SESSION_PRIVATE_KEY = "Failed to decrypt session private key" + const val ERROR_FAILED_ENCRYPT_API_KEY = "Failed to encrypt API key" + const val ERROR_FAILED_DECRYPT_API_KEY = "Failed to decrypt API key" + + // URL Validation Errors + const val ERROR_INVALID_URL_SCHEME_REJECTING = "Invalid URL scheme, rejecting: " + const val ERROR_SESSION_FOR_DOMAIN = " for domain: " + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/util/JsonExtensions.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/util/JsonExtensions.kt new file mode 100644 index 000000000..24104e178 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/data/storage/util/JsonExtensions.kt @@ -0,0 +1,42 @@ +package io.ton.walletkit.data.storage.util + +import io.ton.walletkit.data.model.StoredSessionHint +import io.ton.walletkit.data.model.StoredWalletRecord +import io.ton.walletkit.domain.constants.JsonConstants +import org.json.JSONArray +import org.json.JSONObject + +/** + * Internal JSON extension utilities for storage serialization. + * @suppress Internal utilities. Not part of public API. + */ + +internal fun String.toStoredWalletRecord(): StoredWalletRecord? = runCatching { + val json = JSONObject(this) + val wordsArray = json.optJSONArray(JsonConstants.KEY_WORDS) ?: return null + val words = List(wordsArray.length()) { index -> wordsArray.optString(index) } + StoredWalletRecord( + mnemonic = words, + name = json.stringOrNull(JsonConstants.KEY_NAME), + network = json.stringOrNull(JsonConstants.KEY_NETWORK), + version = json.stringOrNull(JsonConstants.KEY_VERSION), + ) +}.getOrNull() + +internal fun JSONObject.stringOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key) +} + +internal fun StoredWalletRecord.toJson(): JSONObject = JSONObject().apply { + put(JsonConstants.KEY_WORDS, JSONArray(mnemonic)) + name?.let { put(JsonConstants.KEY_NAME, it) } + network?.let { put(JsonConstants.KEY_NETWORK, it) } + version?.let { put(JsonConstants.KEY_VERSION, it) } +} + +internal fun StoredSessionHint.toJson(): JSONObject = JSONObject().apply { + manifestUrl?.let { put(JsonConstants.KEY_MANIFEST_URL, it) } + dAppUrl?.let { put(JsonConstants.KEY_DAPP_URL, it) } + iconUrl?.let { put(JsonConstants.KEY_ICON_URL, it) } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/BridgeMethodConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/BridgeMethodConstants.kt new file mode 100644 index 000000000..453f441be --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/BridgeMethodConstants.kt @@ -0,0 +1,97 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for JavaScript bridge method names. + * + * These constants define the method names used when calling JavaScript functions + * in the WebView-based WalletKit engine. Centralizing these names ensures consistency + * and makes refactoring easier. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object BridgeMethodConstants { + /** + * Method name for initializing the WalletKit bridge. + */ + const val METHOD_INIT = "init" + + /** + * Method name for adding a wallet from mnemonic phrase. + */ + const val METHOD_ADD_WALLET_FROM_MNEMONIC = "addWalletFromMnemonic" + + /** + * Method name for getting all wallets. + */ + const val METHOD_GET_WALLETS = "getWallets" + + /** + * Method name for removing a wallet. + */ + const val METHOD_REMOVE_WALLET = "removeWallet" + + /** + * Method name for getting wallet state (balance, transactions). + */ + const val METHOD_GET_WALLET_STATE = "getWalletState" + + /** + * Method name for getting recent transactions. + */ + const val METHOD_GET_RECENT_TRANSACTIONS = "getRecentTransactions" + + /** + * Method name for handling TON Connect URL. + */ + const val METHOD_HANDLE_TON_CONNECT_URL = "handleTonConnectUrl" + + /** + * Method name for sending a transaction. + */ + const val METHOD_SEND_TRANSACTION = "sendTransaction" + + /** + * Method name for approving a connect request. + */ + const val METHOD_APPROVE_CONNECT_REQUEST = "approveConnectRequest" + + /** + * Method name for rejecting a connect request. + */ + const val METHOD_REJECT_CONNECT_REQUEST = "rejectConnectRequest" + + /** + * Method name for approving a transaction request. + */ + const val METHOD_APPROVE_TRANSACTION_REQUEST = "approveTransactionRequest" + + /** + * Method name for rejecting a transaction request. + */ + const val METHOD_REJECT_TRANSACTION_REQUEST = "rejectTransactionRequest" + + /** + * Method name for approving a sign data request. + */ + const val METHOD_APPROVE_SIGN_DATA_REQUEST = "approveSignDataRequest" + + /** + * Method name for rejecting a sign data request. + */ + const val METHOD_REJECT_SIGN_DATA_REQUEST = "rejectSignDataRequest" + + /** + * Method name for listing all active sessions. + */ + const val METHOD_LIST_SESSIONS = "listSessions" + + /** + * Method name for disconnecting a session. + */ + const val METHOD_DISCONNECT_SESSION = "disconnectSession" + + /** + * Method name for injecting a sign data request (testing/development). + */ + const val METHOD_INJECT_SIGN_DATA_REQUEST = "injectSignDataRequest" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/CryptoConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/CryptoConstants.kt new file mode 100644 index 000000000..81f856776 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/CryptoConstants.kt @@ -0,0 +1,21 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for cryptographic operations and Android KeyStore. + * Defines algorithm specifications, key sizes, and keystore parameters. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object CryptoConstants { + // Keystore and Cipher + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val DEFAULT_KEYSTORE_ALIAS = "walletkit_master_key" + const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" + + // Key Sizes + const val AES_KEY_SIZE = 256 + const val GCM_IV_SIZE = 12 // 96 bits recommended for GCM + + // Log Tags + const val TAG_CRYPTO_MANAGER = "CryptoManager" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/EventTypeConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/EventTypeConstants.kt new file mode 100644 index 000000000..97f772e33 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/EventTypeConstants.kt @@ -0,0 +1,51 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for bridge event type strings. + * + * These constants define the event types that can be received from the JavaScript + * bridge layer. They are used for event routing and handling in the presentation layer. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object EventTypeConstants { + /** + * Event type for TON Connect connection requests. + */ + const val EVENT_CONNECT_REQUEST = "connectRequest" + + /** + * Event type for transaction approval requests. + */ + const val EVENT_TRANSACTION_REQUEST = "transactionRequest" + + /** + * Event type for sign data requests (text, binary, cell). + */ + const val EVENT_SIGN_DATA_REQUEST = "signDataRequest" + + /** + * Event type for session disconnection. + */ + const val EVENT_DISCONNECT = "disconnect" + + /** + * Event type for wallet state changes. + */ + const val EVENT_STATE_CHANGED = "stateChanged" + + /** + * Alternative event type for wallet state changes. + */ + const val EVENT_WALLET_STATE_CHANGED = "walletStateChanged" + + /** + * Event type for sessions list changes. + */ + const val EVENT_SESSIONS_CHANGED = "sessionsChanged" + + /** + * Default value for unknown event types. + */ + const val EVENT_TYPE_UNKNOWN = "unknown" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/JsonConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/JsonConstants.kt new file mode 100644 index 000000000..82e1a3f52 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/JsonConstants.kt @@ -0,0 +1,199 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for JSON object keys used in storage serialization. + * + * These constants ensure consistent JSON key naming when serializing/deserializing + * wallet records, session hints, and bridge configuration. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object JsonConstants { + // Wallet record keys + /** + * JSON key for mnemonic phrase array. + */ + const val KEY_MNEMONIC = "mnemonic" + + /** + * JSON key for wallet words (alternative to mnemonic). + */ + const val KEY_WORDS = "words" + + /** + * JSON key for wallet name. + */ + const val KEY_NAME = "name" + + /** + * JSON key for network identifier. + */ + const val KEY_NETWORK = "network" + + /** + * JSON key for wallet version. + */ + const val KEY_VERSION = "version" + + // Session hint keys + /** + * JSON key for manifest URL. + */ + const val KEY_MANIFEST_URL = "manifestUrl" + + /** + * JSON key for dApp URL. + */ + const val KEY_DAPP_URL = "dAppUrl" + + /** + * JSON key for icon URL. + */ + const val KEY_ICON_URL = "iconUrl" + + // Bridge config keys + /** + * JSON key for API URL configuration. + */ + const val KEY_API_URL = "apiUrl" + + /** + * JSON key for TON API URL configuration. + */ + const val KEY_TON_API_URL = "tonApiUrl" + + /** + * JSON key for bridge URL configuration. + */ + const val KEY_BRIDGE_URL = "bridgeUrl" + + /** + * JSON key for bridge name configuration. + */ + const val KEY_BRIDGE_NAME = "bridgeName" + + // Wallet manifest keys + /** + * JSON key for wallet manifest object. + */ + const val KEY_WALLET_MANIFEST = "walletManifest" + + /** + * JSON key for app name in manifest. + */ + const val KEY_APP_NAME = "appName" + + /** + * JSON key for image URL in manifest. + */ + const val KEY_IMAGE_URL = "imageUrl" + + /** + * JSON key for about URL in manifest. + */ + const val KEY_ABOUT_URL = "aboutUrl" + + /** + * JSON key for universal URL in manifest. + */ + const val KEY_UNIVERSAL_URL = "universalUrl" + + /** + * JSON key for platforms array in manifest. + */ + const val KEY_PLATFORMS = "platforms" + + // Device info keys + /** + * JSON key for device info object. + */ + const val KEY_DEVICE_INFO = "deviceInfo" + + /** + * JSON key for platform in device info. + */ + const val KEY_PLATFORM = "platform" + + /** + * JSON key for app version in device info. + */ + const val KEY_APP_VERSION = "appVersion" + + /** + * JSON key for max protocol version in device info. + */ + const val KEY_MAX_PROTOCOL_VERSION = "maxProtocolVersion" + + /** + * JSON key for features array in device info. + */ + const val KEY_FEATURES = "features" + + // Transaction/Request keys + /** + * JSON key for request ID. + */ + const val KEY_ID = "id" + + /** + * JSON key for sender/from address. + */ + const val KEY_FROM = "from" + + /** + * JSON key for valid until timestamp. + */ + const val KEY_VALID_UNTIL = "valid_until" + + /** + * JSON key for sign data type. + */ + const val KEY_TYPE = "type" + + // Sign data type values + /** + * JSON value for text sign data type. + */ + const val VALUE_SIGN_DATA_TEXT = "text" + + /** + * JSON value for binary sign data type. + */ + const val VALUE_SIGN_DATA_BINARY = "binary" + + /** + * JSON value for cell sign data type. + */ + const val VALUE_SIGN_DATA_CELL = "cell" + + // Transaction feature name + /** + * Feature name for SendTransaction capability. + */ + const val FEATURE_SEND_TRANSACTION = "SendTransaction" + + /** + * Feature name for SignData capability. + */ + const val FEATURE_SIGN_DATA = "SignData" + + /** + * JSON key for max messages in SendTransaction feature. + */ + const val KEY_MAX_MESSAGES = "maxMessages" + + /** + * JSON key for types array in SignData feature. + */ + const val KEY_TYPES = "types" + + /** + * JSON key for transaction hash. + */ + const val KEY_HASH = "hash" + + /** + * JSON key for logical time. + */ + const val KEY_LT = "lt" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/LogConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/LogConstants.kt new file mode 100644 index 000000000..bfefe605c --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/LogConstants.kt @@ -0,0 +1,62 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for logging tags used throughout the SDK. + * + * These constants provide consistent logging tags for different components, + * making it easier to filter and search logs during development and debugging. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object LogConstants { + /** + * Log tag for SecureWalletKitStorage class. + */ + const val TAG_SECURE_STORAGE = "SecureWalletKitStorage" + + /** + * Log tag for SecureBridgeStorageAdapter class. + */ + const val TAG_BRIDGE_STORAGE = "SecureBridgeStorageAdapter" + + /** + * Log tag for WebViewWalletKitEngine class. + */ + const val TAG_WEBVIEW_ENGINE = "WebViewWalletKitEngine" + + // Log messages + /** + * Log message for bridge ready state. + */ + const val MSG_BRIDGE_READY = "bridge ready" + + /** + * Log message prefix for malformed payload from JavaScript. + */ + const val MSG_MALFORMED_PAYLOAD = "Malformed payload from JS" + + /** + * Error message prefix for malformed payloads. + */ + const val ERROR_MALFORMED_PAYLOAD_PREFIX = "Malformed payload: " + + /** + * Log message prefix for storage get failures. + */ + const val MSG_STORAGE_GET_FAILED = "Storage get failed for key: " + + /** + * Log message prefix for storage set failures. + */ + const val MSG_STORAGE_SET_FAILED = "Storage set failed for key: " + + /** + * Log message prefix for storage remove failures. + */ + const val MSG_STORAGE_REMOVE_FAILED = "Storage remove failed for key: " + + /** + * Log message for storage clear failures. + */ + const val MSG_STORAGE_CLEAR_FAILED = "Storage clear failed" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/MiscConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/MiscConstants.kt new file mode 100644 index 000000000..2ef818c69 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/MiscConstants.kt @@ -0,0 +1,20 @@ +package io.ton.walletkit.domain.constants + +/** + * Miscellaneous string constants used throughout the WalletKit SDK. + * Contains various literals that don't fit into other constant categories. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object MiscConstants { + // String Delimiters and Separators + const val SPACE_DELIMITER = " " + const val EMPTY_STRING = "" + + // URL Schemes + const val SCHEME_HTTPS = "https://" + const val SCHEME_HTTP = "http://" + + // Numeric Constants + const val BRIDGE_STORAGE_KEYS_COUNT_SUFFIX = " bridge storage keys" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/NetworkConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/NetworkConstants.kt new file mode 100644 index 000000000..bc3ffb087 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/NetworkConstants.kt @@ -0,0 +1,56 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for TON network configuration and endpoints. + * + * These constants define the available networks and their default API endpoints + * used throughout the SDK. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object NetworkConstants { + /** + * Mainnet network identifier. + */ + const val NETWORK_MAINNET = "mainnet" + + /** + * Testnet network identifier (default for development). + */ + const val NETWORK_TESTNET = "testnet" + + /** + * Default network used by the SDK. + */ + const val DEFAULT_NETWORK = NETWORK_TESTNET + + /** + * Default TON API URL for testnet. + */ + const val DEFAULT_TESTNET_API_URL = "https://testnet.tonapi.io" + + /** + * Default TON API URL for mainnet. + */ + const val DEFAULT_MAINNET_API_URL = "https://tonapi.io" + + /** + * Default wallet image URL. + */ + const val DEFAULT_WALLET_IMAGE_URL = "https://wallet.ton.org/assets/ui/qr-logo.png" + + /** + * Default wallet about URL. + */ + const val DEFAULT_WALLET_ABOUT_URL = "https://wallet.ton.org" + + /** + * Default app version when unable to retrieve from package manager. + */ + const val DEFAULT_APP_VERSION = "1.0.0" + + /** + * Maximum protocol version supported by the SDK. + */ + const val MAX_PROTOCOL_VERSION = 2 +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/ReflectionConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/ReflectionConstants.kt new file mode 100644 index 000000000..00b607a27 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/ReflectionConstants.kt @@ -0,0 +1,33 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for reflection-based class loading. + * + * These constants define fully qualified class names used for reflective + * instantiation of engine implementations, particularly for QuickJS support + * in the full variant. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object ReflectionConstants { + /** + * Fully qualified class name for QuickJsWalletKitEngine. + * + * Used for reflection-based loading in full variant AAR. + */ + const val CLASS_QUICKJS_ENGINE = "io.ton.walletkit.presentation.impl.QuickJsWalletKitEngine" + + /** + * Fully qualified class name for OkHttpClient. + * + * Used for reflection-based constructor parameter type matching. + */ + const val CLASS_OKHTTP_CLIENT = "okhttp3.OkHttpClient" + + /** + * Error message when QuickJS engine is not available. + */ + const val ERROR_QUICKJS_NOT_AVAILABLE = + "QuickJS engine is not available in this SDK variant. " + + "Use the 'full' variant AAR to access QuickJS, or use WalletKitEngineKind.WEBVIEW instead." +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/ResponseConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/ResponseConstants.kt new file mode 100644 index 000000000..663a0ee73 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/ResponseConstants.kt @@ -0,0 +1,415 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for JSON response field names and special values. + * + * These constants represent commonly used field names in JSON responses + * from the JavaScript bridge layer, including pagination, results, and + * special values used in transaction parsing. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object ResponseConstants { + // Common response structure keys + /** + * JSON key for response result object/value. + */ + const val KEY_RESULT = "result" + + /** + * JSON key for response error object. + */ + const val KEY_ERROR = "error" + + /** + * JSON key for error message. + */ + const val KEY_MESSAGE = "message" + + /** + * JSON key for signature in sign data responses. + */ + const val KEY_SIGNATURE = "signature" + + /** + * JSON key for preview data in requests. + */ + const val KEY_PREVIEW = "preview" + + /** + * JSON key for data payload. + */ + const val KEY_DATA = "data" + + /** + * JSON key for event object. + */ + const val KEY_EVENT = "event" + + /** + * JSON key for reason string. + */ + const val KEY_REASON = "reason" + + // List/collection response keys + /** + * JSON key for items array in list responses. + */ + const val KEY_ITEMS = "items" + + /** + * JSON key for limit in pagination. + */ + const val KEY_LIMIT = "limit" + + // Wallet keys + /** + * JSON key for wallet object. + */ + const val KEY_WALLET = "wallet" + + /** + * JSON key for wallet address. + */ + const val KEY_ADDRESS = "address" + + /** + * JSON key for wallet address (alternative format). + */ + const val KEY_WALLET_ADDRESS = "walletAddress" + + /** + * JSON key for public key. + */ + const val KEY_PUBLIC_KEY = "publicKey" + + /** + * JSON key for wallet balance. + */ + const val KEY_BALANCE = "balance" + + /** + * JSON key for generic value field. + */ + const val KEY_VALUE = "value" + + /** + * JSON key for wallet index. + */ + const val KEY_INDEX = "index" + + // Transaction keys + /** + * JSON key for transactions array. + */ + const val KEY_TRANSACTIONS = "transactions" + + /** + * JSON key for transaction hash (hex format). + */ + const val KEY_HASH_HEX = "hash_hex" + + /** + * JSON key for incoming message object. + */ + const val KEY_IN_MSG = "in_msg" + + /** + * JSON key for outgoing messages array. + */ + const val KEY_OUT_MSGS = "out_msgs" + + /** + * JSON key for operation code. + */ + const val KEY_OP_CODE = "op_code" + + /** + * JSON key for message body. + */ + const val KEY_BODY = "body" + + // KEY_MESSAGE already defined above - removed duplicate + + /** + * JSON key for transaction comment. + */ + const val KEY_COMMENT = "comment" + + /** + * JSON key for total transaction fees. + */ + const val KEY_TOTAL_FEES = "total_fees" + + /** + * JSON key for timestamp (Unix time). + */ + const val KEY_NOW = "now" + + /** + * JSON key for masterchain block sequence number. + */ + const val KEY_MC_BLOCK_SEQNO = "mc_block_seqno" + + /** + * JSON key for source address (friendly format). + */ + const val KEY_SOURCE_FRIENDLY = "source_friendly" + + /** + * JSON key for source address (raw format). + */ + const val KEY_SOURCE = "source" + + /** + * JSON key for destination address (friendly format). + */ + const val KEY_DESTINATION_FRIENDLY = "destination_friendly" + + /** + * JSON key for destination address (raw format). + */ + const val KEY_DESTINATION = "destination" + + /** + * JSON key for amount value. + */ + const val KEY_AMOUNT = "amount" + + /** + * JSON key for recipient address. + */ + const val KEY_TO_ADDRESS = "toAddress" + + /** + * JSON key for URL parameter. + */ + const val KEY_URL = "url" + + // Session keys + /** + * JSON key for session ID. + */ + const val KEY_SESSION_ID = "sessionId" + + /** + * JSON key for dApp name. + */ + const val KEY_DAPP_NAME = "dAppName" + + /** + * JSON key for dApp description. + */ + const val KEY_DAPP_DESCRIPTION = "dAppDescription" + + /** + * JSON key for dApp domain. + */ + const val KEY_DOMAIN = "domain" + + /** + * JSON key for dApp icon URL. + */ + const val KEY_DAPP_ICON_URL = "dAppIconUrl" + + /** + * JSON key for creation timestamp. + */ + const val KEY_CREATED_AT = "createdAt" + + /** + * JSON key for last activity timestamp. + */ + const val KEY_LAST_ACTIVITY = "lastActivity" + + /** + * JSON key for last activity timestamp (alternative format). + */ + const val KEY_LAST_ACTIVITY_AT = "lastActivityAt" + + /** + * JSON key for private key (encrypted). + */ + const val KEY_PRIVATE_KEY = "privateKey" + + // Config keys + /** + * JSON key for TON client endpoint. + */ + const val KEY_TON_CLIENT_ENDPOINT = "tonClientEndpoint" + + /** + * JSON key for API key. + */ + const val KEY_API_KEY = "apiKey" + + // Preferences keys + /** + * JSON key for active wallet address preference. + */ + const val KEY_ACTIVE_WALLET_ADDRESS = "activeWalletAddress" + + /** + * JSON key for last selected network preference. + */ + const val KEY_LAST_SELECTED_NETWORK = "lastSelectedNetwork" + + // Boolean response keys + /** + * JSON key for 'ok' status flag. + */ + const val KEY_OK = "ok" + + /** + * JSON key for 'removed' status flag. + */ + const val KEY_REMOVED = "removed" + + /** + * JSON key for manifest object. + */ + const val KEY_MANIFEST = "manifest" + + /** + * JSON key for permissions array. + */ + const val KEY_PERMISSIONS = "permissions" + + /** + * JSON key for request object. + */ + const val KEY_REQUEST = "request" + + /** + * JSON key for messages array in transactions. + */ + const val KEY_MESSAGES = "messages" + + /** + * JSON key for 'to' address (recipient). + */ + const val KEY_TO = "to" + + /** + * JSON key for recipient address (alternative). + */ + const val KEY_RECIPIENT = "recipient" + + /** + * JSON key for comment text (alternative). + */ + const val KEY_TEXT = "text" + + /** + * JSON key for payload data. + */ + const val KEY_PAYLOAD = "payload" + + /** + * JSON key for schema information. + */ + const val KEY_SCHEMA = "schema" + + /** + * JSON key for params array. + */ + const val KEY_PARAMS = "params" + + /** + * JSON key for schema CRC value. + */ + const val KEY_SCHEMA_CRC = "schema_crc" + + /** + * JSON key for network identifier. + */ + const val KEY_NETWORK = "network" + + /** + * JSON key for TON API URL. + */ + const val KEY_TON_API_URL = "tonApiUrl" + + /** + * JSON key for dApp URL (alternative). + */ + const val KEY_DAPP_URL_ALT = "dAppUrl" + + /** + * JSON key for icon URL (alternative). + */ + const val KEY_ICON_URL_ALT = "iconUrl" + + /** + * JSON key for manifest URL (alternative). + */ + const val KEY_MANIFEST_URL_ALT = "manifestUrl" + + /** + * JSON key for message kind/type. + */ + const val KEY_KIND = "kind" + + /** + * JSON key for message type. + */ + const val KEY_TYPE = "type" + + /** + * JSON key for message ID. + */ + const val KEY_ID = "id" + + // Kind/Type values + /** + * Value for 'ready' message kind/type. + */ + const val VALUE_KIND_READY = "ready" + + /** + * Value for 'event' message kind. + */ + const val VALUE_KIND_EVENT = "event" + + /** + * Value for 'response' message kind. + */ + const val VALUE_KIND_RESPONSE = "response" + + /** + * Schema type value for text data. + */ + const val VALUE_SCHEMA_TEXT = "text" + + /** + * Schema type value for binary data. + */ + const val VALUE_SCHEMA_BINARY = "binary" + + /** + * Schema type value for cell data. + */ + const val VALUE_SCHEMA_CELL = "cell" + + // Special values + /** + * Empty TON cell body in base64 format. + * Used to detect empty messages in transaction parsing. + */ + const val VALUE_EMPTY_CELL_BODY = "te6ccgEBAQEAAgAAAA==" + + /** + * Default value for unknown/missing dApp names. + */ + const val VALUE_UNKNOWN_DAPP = "Unknown dApp" + + /** + * Default value for unknown strings. + */ + const val VALUE_UNKNOWN = "unknown" + + /** + * Default error message for bridge errors without specific message. + */ + const val ERROR_MESSAGE_DEFAULT = "WalletKit bridge error" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/StorageConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/StorageConstants.kt new file mode 100644 index 000000000..3d813541e --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/StorageConstants.kt @@ -0,0 +1,81 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for storage keys and prefixes used throughout the SDK. + * + * These constants ensure consistent key naming across storage implementations + * and make refactoring safer by avoiding hardcoded string literals. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object StorageConstants { + /** + * Prefix for wallet-related storage keys. + * + * Format: "wallet:{accountId}" + */ + const val KEY_PREFIX_WALLET = "wallet:" + + /** + * Prefix for bridge-related storage keys (used by JavaScript bundle). + * + * Format: "bridge:{key}" + */ + const val KEY_PREFIX_BRIDGE = "bridge:" + + /** + * Prefix for session-related storage keys. + * + * Format: "session:{sessionId}" + */ + const val KEY_PREFIX_SESSION = "session:" + + /** + * Prefix for configuration storage keys. + * + * Format: "config:{configId}" + */ + const val KEY_PREFIX_CONFIG = "config:" + + /** + * Prefix for user preferences storage keys. + * + * Format: "preferences:{prefKey}" + */ + const val KEY_PREFIX_PREFERENCES = "preferences:" + + /** + * Default name for secure storage SharedPreferences file. + */ + const val DEFAULT_SECURE_STORAGE_NAME = "walletkit_secure_storage" + + /** + * Name for bridge storage SharedPreferences file. + */ + const val BRIDGE_STORAGE_NAME = "walletkit_bridge_storage" + + /** + * Keystore alias for mnemonic encryption. + */ + const val MNEMONIC_KEYSTORE_KEY = "walletkit_mnemonic_key" + + /** + * Keystore alias for session private key encryption. + */ + const val SESSION_KEYSTORE_KEY = "walletkit_session_key" + + /** + * Keystore alias for API key encryption. + */ + const val API_KEY_KEYSTORE_KEY = "walletkit_apikey" + + /** + * Storage key for bridge configuration. + */ + const val BRIDGE_CONFIG_KEY = "bridge_config" + + /** + * Storage key for user preferences. + */ + const val USER_PREFERENCES_KEY = "user_preferences" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/WebViewConstants.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/WebViewConstants.kt new file mode 100644 index 000000000..f1bf23d5e --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/constants/WebViewConstants.kt @@ -0,0 +1,91 @@ +package io.ton.walletkit.domain.constants + +/** + * Constants for WebView configuration and JavaScript interface. + * + * These constants define the WebView setup, asset paths, and JavaScript + * interface names used for communication between Kotlin and JS. + * + * @suppress Internal implementation constants. Not part of public API. + */ +internal object WebViewConstants { + /** + * Name of the JavaScript interface exposed to WebView. + * + * JavaScript code can call methods on this interface via: + * `window.WalletKitNative.methodName()` + */ + const val JS_INTERFACE_NAME = "WalletKitNative" + + /** + * Default asset path for the WebView HTML entry point. + */ + const val DEFAULT_ASSET_PATH = "walletkit/index.html" + + /** + * Default asset directory for QuickJS engine. + */ + const val DEFAULT_QUICKJS_ASSET_DIR = "walletkit" + + /** + * Asset loader domain for WebView. + * + * Used to create secure URLs like: https://appassets.androidplatform.net/assets/ + */ + const val ASSET_LOADER_DOMAIN = "appassets.androidplatform.net" + + /** + * Asset loader path prefix. + */ + const val ASSET_LOADER_PATH = "/assets/" + + /** + * Platform identifier for device info. + */ + const val PLATFORM_ANDROID = "android" + + /** + * Log tag for WebView engine. + */ + const val LOG_TAG_WEBVIEW = "WebViewWalletKitEngine" + + /** + * Log tag for QuickJS engine. + */ + const val LOG_TAG_QUICKJS = "QuickJsWalletKitEngine" + + /** + * Error message for bundle loading failure. + */ + const val ERROR_BUNDLE_LOAD_FAILED = "Failed to load WalletKit bundle" + + /** + * Error message prefix for WebView load failures. + */ + const val ERROR_WEBVIEW_LOAD_PREFIX = "WebView load failed: " + + /** + * Instruction message shown when bundle fails to load. + */ + const val BUILD_INSTRUCTION = "Run `pnpm -w --filter androidkit build` and recompile." + + /** + * JavaScript function call template for WalletKit bridge. + */ + const val JS_FUNCTION_WALLETKIT_CALL = "window.__walletkitCall" + + /** + * JavaScript function name for base64 decoding. + */ + const val JS_FUNCTION_ATOB = "atob" + + /** + * Null literal for JavaScript. + */ + const val JS_NULL = "null" + + /** + * URL prefix for loading assets. + */ + const val URL_PREFIX_HTTPS = "https://" +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/DAppInfo.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/DAppInfo.kt new file mode 100644 index 000000000..ef62b3e5a --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/DAppInfo.kt @@ -0,0 +1,23 @@ +package io.ton.walletkit.domain.model + +import kotlinx.serialization.Serializable + +/** + * Information about a decentralized application (dApp) requesting wallet interaction. + * + * This model represents metadata about a dApp that is initiating a connection, + * transaction, or data signing request with the wallet. It's used throughout + * the SDK to provide context about which application is making requests. + * + * @property name Display name of the dApp (e.g., "TON Wallet") + * @property url Main URL of the dApp (e.g., "https://wallet.ton.org") + * @property iconUrl Optional URL to the dApp's icon/logo for display purposes + * @property manifestUrl Optional URL to the TON Connect manifest file containing dApp metadata + */ +@Serializable +data class DAppInfo( + val name: String, + val url: String, + val iconUrl: String? = null, + val manifestUrl: String? = null, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/SignDataRequest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/SignDataRequest.kt new file mode 100644 index 000000000..836382b49 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/SignDataRequest.kt @@ -0,0 +1,16 @@ +package io.ton.walletkit.domain.model + +/** + * Represents a request from a dApp to sign arbitrary data. + * + * This is used for scenarios where a dApp needs cryptographic proof + * of wallet ownership or wants to sign a message without creating + * a blockchain transaction. + * + * @property payload The data to be signed, typically encoded as base64 or hex + * @property schema Optional identifier for the data schema/format (e.g., "ton-proof-item-v2") + */ +data class SignDataRequest( + val payload: String, + val schema: String? = null, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/SignDataResult.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/SignDataResult.kt new file mode 100644 index 000000000..99ecb061b --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/SignDataResult.kt @@ -0,0 +1,10 @@ +package io.ton.walletkit.domain.model + +/** + * Result of a sign data operation. + * + * @property signature Base64-encoded signature + */ +data class SignDataResult( + val signature: String, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/Transaction.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/Transaction.kt new file mode 100644 index 000000000..be81c43be --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/Transaction.kt @@ -0,0 +1,28 @@ +package io.ton.walletkit.domain.model + +/** + * Represents a blockchain transaction. + * + * @property hash Unique transaction hash + * @property timestamp Transaction timestamp in milliseconds + * @property amount Transaction amount in nanoTON + * @property fee Transaction fee in nanoTON (nullable if not yet confirmed) + * @property comment Optional comment/message attached to the transaction + * @property sender Sender address (nullable for outgoing transactions) + * @property recipient Recipient address (nullable for incoming transactions) + * @property type Type of transaction (incoming, outgoing, or unknown) + * @property lt Logical time - transaction ordering in the blockchain + * @property blockSeqno Block sequence number where transaction was included + */ +data class Transaction( + val hash: String, + val timestamp: Long, + val amount: String, + val fee: String? = null, + val comment: String? = null, + val sender: String? = null, + val recipient: String? = null, + val type: TransactionType = TransactionType.UNKNOWN, + val lt: String? = null, + val blockSeqno: Int? = null, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/TransactionRequest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/TransactionRequest.kt new file mode 100644 index 000000000..58435b0d6 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/TransactionRequest.kt @@ -0,0 +1,20 @@ +package io.ton.walletkit.domain.model + +/** + * Represents a transaction request initiated by a dApp. + * + * This model contains all the necessary information to construct and send + * a blockchain transaction. It's typically received from a dApp through + * the TON Connect protocol. + * + * @property recipient The destination wallet address on the TON blockchain + * @property amount Transaction amount in nanoTON (1 TON = 1,000,000,000 nanoTON) + * @property comment Optional text comment/memo attached to the transaction + * @property payload Optional raw cell payload for complex contract interactions + */ +data class TransactionRequest( + val recipient: String, + val amount: String, + val comment: String? = null, + val payload: String? = null, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/TransactionType.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/TransactionType.kt new file mode 100644 index 000000000..c47488adb --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/TransactionType.kt @@ -0,0 +1,27 @@ +package io.ton.walletkit.domain.model + +/** + * Categorizes the direction and nature of a blockchain transaction. + * + * This enum helps determine how a transaction should be displayed + * in the user interface and how amounts should be formatted. + */ +enum class TransactionType { + /** + * Transaction where the wallet receives funds from another address. + * Amount should typically be displayed with a positive indicator. + */ + INCOMING, + + /** + * Transaction where the wallet sends funds to another address. + * Amount should typically be displayed with a negative indicator. + */ + OUTGOING, + + /** + * Transaction type could not be determined. + * This may occur for complex contract interactions or parsing errors. + */ + UNKNOWN, +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletAccount.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletAccount.kt new file mode 100644 index 000000000..04ce55736 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletAccount.kt @@ -0,0 +1,20 @@ +package io.ton.walletkit.domain.model + +/** + * Represents a wallet account managed by WalletKit. + * + * @property address Wallet address + * @property publicKey Public key of the wallet (nullable if not available) + * @property name User-assigned name for the wallet (nullable) + * @property version Wallet version (e.g., "v5r1", "v4r2") + * @property network Network the wallet operates on (e.g., "mainnet", "testnet") + * @property index Wallet derivation index + */ +data class WalletAccount( + val address: String, + val publicKey: String?, + val name: String? = null, + val version: String, + val network: String, + val index: Int, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletSession.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletSession.kt new file mode 100644 index 000000000..12eba50d2 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletSession.kt @@ -0,0 +1,28 @@ +package io.ton.walletkit.domain.model + +/** + * Represents an active TON Connect session between a wallet and a dApp. + * + * A session is established when a user connects their wallet to a decentralized application. + * It persists across app restarts and allows the dApp to request transactions and signatures + * without requiring the user to reconnect. + * + * @property sessionId Unique identifier for this session + * @property dAppName Display name of the connected dApp + * @property walletAddress The wallet address used for this session + * @property dAppUrl Optional URL of the dApp's website + * @property manifestUrl Optional URL to the dApp's TON Connect manifest + * @property iconUrl Optional URL to the dApp's icon/logo + * @property createdAtIso ISO 8601 timestamp when the session was created (nullable if unknown) + * @property lastActivityIso ISO 8601 timestamp of the last activity on this session (nullable if unknown) + */ +data class WalletSession( + val sessionId: String, + val dAppName: String, + val walletAddress: String, + val dAppUrl: String?, + val manifestUrl: String?, + val iconUrl: String?, + val createdAtIso: String?, + val lastActivityIso: String?, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletState.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletState.kt new file mode 100644 index 000000000..2afef6d17 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/domain/model/WalletState.kt @@ -0,0 +1,12 @@ +package io.ton.walletkit.domain.model + +/** + * Current state of a wallet. + * + * @property balance Wallet balance in nanoTON, null if not yet fetched + * @property transactions Recent transactions for this wallet + */ +data class WalletState( + val balance: String?, + val transactions: List = emptyList(), +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitBridgeException.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitBridgeException.kt new file mode 100644 index 000000000..0d95f1405 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitBridgeException.kt @@ -0,0 +1,28 @@ +package io.ton.walletkit.presentation + +/** + * Exception thrown when an error occurs during WalletKit bridge operations. + * + * This exception is thrown when the JavaScript bridge encounters issues such as: + * - Bridge initialization failures + * - Invalid responses from the JavaScript layer + * - Communication errors between Kotlin and JavaScript + * - JavaScript runtime exceptions that propagate to the native layer + * - Timeout errors when waiting for bridge responses + * + * Example scenarios: + * ``` + * // Bridge not initialized + * throw WalletKitBridgeException("Bridge not initialized") + * + * // Invalid JSON response + * throw WalletKitBridgeException("Failed to parse bridge response: $jsonString") + * + * // JavaScript error + * throw WalletKitBridgeException("JavaScript error: ${error.message}") + * ``` + * + * @property message A descriptive error message explaining what went wrong + * @see io.ton.walletkit.presentation.WalletKitEngine + */ +class WalletKitBridgeException(message: String) : Exception(message) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngine.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngine.kt new file mode 100644 index 000000000..f9915dcdf --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngine.kt @@ -0,0 +1,218 @@ +package io.ton.walletkit.presentation + +import io.ton.walletkit.domain.model.SignDataResult +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.WalletAccount +import io.ton.walletkit.domain.model.WalletSession +import io.ton.walletkit.domain.model.WalletState +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.event.ConnectRequestEvent +import io.ton.walletkit.presentation.event.SignDataRequestEvent +import io.ton.walletkit.presentation.event.TransactionRequestEvent +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import java.io.Closeable + +/** + * Abstraction over a runtime that can execute the WalletKit JavaScript bundle and expose + * the wallet APIs to Android callers. Implementations may back the runtime with a WebView or + * an embedded JavaScript engine such as QuickJS. Every implementation must provide the same + * JSON-RPC surface as the historical [WalletKitBridge] class. + * + * **Auto-Initialization:** + * All methods that require WalletKit initialization will automatically initialize the SDK + * if it hasn't been initialized yet. This means calling [init] explicitly is optional - + * you can call any method and initialization will happen automatically with default settings. + * If you need custom configuration, call [init] with your config before other methods. + * + * **Event Handling:** + * Supports [addEventHandler] with [WalletKitEventHandler] for type-safe sealed events. + */ +interface WalletKitEngine { + val kind: WalletKitEngineKind + + /** + * Add an event handler for type-safe event handling. + * Use this for exhaustive when() expressions with the sealed WalletKitEvent class. + * + * @param handler Event handler that receives typed WalletKitEvent sealed class instances + * @return Closeable to remove the handler + */ + fun addEventHandler(handler: WalletKitEventHandler): Closeable + + /** + * Initialize WalletKit with custom configuration. This method is optional - if not called, + * initialization will happen automatically on first use with default settings. + * + * @param config Configuration for the WalletKit SDK + * @throws WalletKitBridgeException if initialization fails + */ + suspend fun init(config: WalletKitBridgeConfig = WalletKitBridgeConfig()) + + /** + * Add a new wallet from mnemonic phrase. + * + * @param words Mnemonic phrase as a list of words + * @param name Optional user-assigned name for the wallet + * @param version Wallet version (e.g., "v5r1", "v4r2") + * @param network Network to use (e.g., "mainnet", "testnet"), defaults to current network + * @return The newly added wallet account + * @throws WalletKitBridgeException if wallet creation fails + */ + suspend fun addWalletFromMnemonic( + words: List, + name: String? = null, + version: String, + network: String? = null, + ): WalletAccount + + /** + * Get all wallets managed by this engine. + * + * @return List of wallet accounts + */ + suspend fun getWallets(): List + + /** + * Remove a wallet by address. + * + * @param address Wallet address to remove + * @throws WalletKitBridgeException if removal fails + */ + suspend fun removeWallet(address: String) + + /** + * Get the current state of a wallet. + * + * @param address Wallet address + * @return Current wallet state including balance and transactions + * @throws WalletKitBridgeException if state retrieval fails + */ + suspend fun getWalletState(address: String): WalletState + + /** + * Get recent transactions for a wallet. + * + * @param address Wallet address + * @param limit Maximum number of transactions to return (default 10) + * @return List of recent transactions + * @throws WalletKitBridgeException if transaction retrieval fails + */ + suspend fun getRecentTransactions(address: String, limit: Int = 10): List + + /** + * Handle a TON Connect URL (e.g., from QR code scan or deep link). + * This will trigger appropriate events (connect request, transaction request, etc.) + * + * @param url TON Connect URL to handle + * @throws WalletKitBridgeException if URL handling fails + */ + suspend fun handleTonConnectUrl(url: String) + + /** + * Create a new transaction request. + * This will trigger a transaction request event that needs to be approved via [approveTransaction]. + * + * @param walletAddress Source wallet address + * @param recipient Recipient address + * @param amount Amount in nanoTON + * @param comment Optional comment/message + * @throws WalletKitBridgeException if transaction creation fails + */ + suspend fun sendTransaction( + walletAddress: String, + recipient: String, + amount: String, + comment: String? = null, + ) + + /** + * Approve a connection request from a dApp. + * + * @param event Typed event from the connect request + * @throws WalletKitBridgeException if approval fails + */ + suspend fun approveConnect(event: ConnectRequestEvent) + + /** + * Reject a connection request from a dApp. + * + * @param event Typed event from the connect request + * @param reason Optional reason for rejection + * @throws WalletKitBridgeException if rejection fails + */ + suspend fun rejectConnect( + event: ConnectRequestEvent, + reason: String? = null, + ) + + /** + * Approve and sign a transaction request. + * + * @param event Typed event from the transaction request + * @throws WalletKitBridgeException if approval or signing fails + */ + suspend fun approveTransaction(event: TransactionRequestEvent) + + /** + * Reject a transaction request. + * + * @param event Typed event from the transaction request + * @param reason Optional reason for rejection + * @throws WalletKitBridgeException if rejection fails + */ + suspend fun rejectTransaction( + event: TransactionRequestEvent, + reason: String? = null, + ) + + /** + * Approve and sign a data signing request. + * + * @param event Typed event from the sign data request + * @return Signature result containing the base64-encoded signature + * @throws WalletKitBridgeException if approval or signing fails + */ + suspend fun approveSignData(event: SignDataRequestEvent): SignDataResult + + /** + * Reject a data signing request. + * + * @param event Typed event from the sign data request + * @param reason Optional reason for rejection + * @throws WalletKitBridgeException if rejection fails + */ + suspend fun rejectSignData( + event: SignDataRequestEvent, + reason: String? = null, + ) + + /** + * Get all active TON Connect sessions. + * + * @return List of active sessions + */ + suspend fun listSessions(): List + + /** + * Disconnect a TON Connect session. + * + * @param sessionId Session ID to disconnect, or null to disconnect all sessions + * @throws WalletKitBridgeException if disconnection fails + */ + suspend fun disconnectSession(sessionId: String? = null) + + /** + * Destroy the engine and release all resources. + */ + suspend fun destroy() + + /** + * Test API: Inject a sign data request for testing purposes. + * This simulates receiving a sign data request from a dApp and will trigger + * the normal sign data flow including actual cryptographic signing. + * + * @param requestData Request data as JSONObject (test API, not yet typed) + * @return JSONObject response (test API, not yet typed) + */ + suspend fun injectSignDataRequest(requestData: org.json.JSONObject): org.json.JSONObject +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngineFactory.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngineFactory.kt new file mode 100644 index 000000000..1fc597b96 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngineFactory.kt @@ -0,0 +1,94 @@ +package io.ton.walletkit.presentation + +import android.content.Context +import io.ton.walletkit.domain.constants.ReflectionConstants +import io.ton.walletkit.domain.constants.WebViewConstants + +/** + * Factory for creating WalletKitEngine instances without directly referencing implementation classes. + * This allows demo apps and partners to work with any SDK variant (webview-only or full) + * without compile-time dependencies on unavailable engine implementations. + */ +object WalletKitEngineFactory { + /** + * Create a WalletKitEngine instance for the specified kind. + * + * @param context Android application context + * @param kind The engine kind to create (WEBVIEW or QUICKJS) + * @return WalletKitEngine instance + * @throws IllegalStateException if the requested engine is not available in this SDK variant + * + * @sample + * ```kotlin + * // Works with both webview-only and full SDK variants + * val engine = WalletKitEngineFactory.create(context, WalletKitEngineKind.WEBVIEW) + * ``` + */ + @Suppress("DEPRECATION") // QuickJS still supported in full variant + fun create(context: Context, kind: WalletKitEngineKind = WalletKitEngineKind.WEBVIEW): WalletKitEngine { + return when (kind) { + WalletKitEngineKind.WEBVIEW -> { + // WebViewWalletKitEngine is always available in all variants + createWebViewEngine(context) + } + WalletKitEngineKind.QUICKJS -> { + // QuickJsWalletKitEngine is only available in full variant + createQuickJsEngine(context) + } + } + } + + /** + * Check if a specific engine kind is available in the current SDK variant. + * + * @param kind The engine kind to check + * @return true if the engine is available, false otherwise + */ + fun isAvailable(kind: WalletKitEngineKind): Boolean { + return when (kind) { + WalletKitEngineKind.WEBVIEW -> true // Always available + WalletKitEngineKind.QUICKJS -> { + try { + // Check if QuickJS class exists (only in full variant) + Class.forName(ReflectionConstants.CLASS_QUICKJS_ENGINE) + true + } catch (e: ClassNotFoundException) { + false + } + } + } + } + + private fun createWebViewEngine(context: Context): WalletKitEngine { + // Direct instantiation - WebViewWalletKitEngine is in the same module + return io.ton.walletkit.presentation.impl.WebViewWalletKitEngine(context) + } + + private fun createQuickJsEngine(context: Context): WalletKitEngine { + try { + // Use reflection only for QuickJS to avoid compile-time dependency in webview variant + val clazz = Class.forName(ReflectionConstants.CLASS_QUICKJS_ENGINE) + // QuickJsWalletKitEngine has additional constructor parameters with defaults + // Try the primary constructor: (Context, String, OkHttpClient) + try { + val okHttpClientClass = Class.forName(ReflectionConstants.CLASS_OKHTTP_CLIENT) + val constructor = clazz.getConstructor(Context::class.java, String::class.java, okHttpClientClass) + // Use null for optional parameters to use defaults (Kotlin handles this via synthetic methods) + // Actually, we need to invoke with actual default values + val defaultAssetPath = WebViewConstants.DEFAULT_QUICKJS_ASSET_DIR + val okHttpClientConstructor = okHttpClientClass.getConstructor() + val defaultHttpClient = okHttpClientConstructor.newInstance() + return constructor.newInstance(context, defaultAssetPath, defaultHttpClient) as WalletKitEngine + } catch (e: NoSuchMethodException) { + // Fallback: try single-arg constructor if it exists + val constructor = clazz.getConstructor(Context::class.java) + return constructor.newInstance(context) as WalletKitEngine + } + } catch (e: ClassNotFoundException) { + throw IllegalStateException( + ReflectionConstants.ERROR_QUICKJS_NOT_AVAILABLE, + e, + ) + } + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngineKind.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngineKind.kt new file mode 100644 index 000000000..79467f10a --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/WalletKitEngineKind.kt @@ -0,0 +1,47 @@ +package io.ton.walletkit.presentation + +/** + * Identifies which JavaScript runtime engine implementation is being used by the SDK. + * + * The engine kind determines how the WalletKit JavaScript bundle is executed. + * Different engines have different performance characteristics and availability + * across SDK variants. + * + * @see io.ton.walletkit.presentation.WalletKitEngine + * @see io.ton.walletkit.presentation.WalletKitEngineFactory + */ +enum class WalletKitEngineKind { + /** + * WebView-based JavaScript engine (recommended for all use cases). + * + * This engine uses Android's WebView component to execute the WalletKit + * JavaScript bundle. It provides: + * - 2x faster performance compared to QuickJS + * - Active maintenance and updates + * - Production-ready stability + * - Available in all SDK variants (webview-only and full) + */ + WEBVIEW, + + /** + * QuickJS-based JavaScript engine (deprecated). + * + * This engine uses a native QuickJS runtime compiled for Android. + * **Note:** This option is deprecated and should not be used for new projects. + * + * Limitations: + * - 2x slower than WebView + * - No longer actively maintained + * - Only available in the 'full' SDK variant + * - Larger AAR size due to native libraries + * + * @deprecated QuickJS is deprecated due to performance and maintenance concerns. + * Use [WEBVIEW] instead. See QUICKJS_DEPRECATION.md for migration guide. + */ + @Deprecated( + message = "QuickJS is deprecated. Use WEBVIEW instead for 2x better performance.", + replaceWith = ReplaceWith("WalletKitEngineKind.WEBVIEW"), + level = DeprecationLevel.WARNING, + ) + QUICKJS, +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/config/WalletKitBridgeConfig.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/config/WalletKitBridgeConfig.kt new file mode 100644 index 000000000..e1fe4c805 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/config/WalletKitBridgeConfig.kt @@ -0,0 +1,51 @@ +package io.ton.walletkit.presentation.config + +import io.ton.walletkit.domain.constants.JsonConstants +import io.ton.walletkit.domain.constants.NetworkConstants + +/** + * Configuration for WalletKit bridge initialization. + * + * @property network Network to use ("mainnet" or "testnet") + * @property apiUrl Optional custom API URL for TON client + * @property bridgeUrl Optional custom bridge URL for TonConnect + * @property bridgeName Optional custom bridge name + * @property tonClientEndpoint Optional custom TON client endpoint + * @property tonApiUrl Optional custom TON API URL + * @property apiKey Optional API key for TON API + * @property enablePersistentStorage Enable persistent storage for wallets and sessions (default: true). + * When false, all data is stored in memory and cleared on app restart. + * Use cases for disabling: + * - Testing/development (quick reset) + * - Privacy-focused session-only mode + * - Kiosk/demo applications + * - Compliance requirements for ephemeral storage + * @property appName Application name to report to dApps (defaults to app label from manifest) + * @property appVersion Application version to report to dApps (defaults to versionName from manifest) + * @property maxMessages Maximum number of messages supported in SendTransaction (default: 4) + * @property signDataTypes Supported sign data types (default: ["text", "binary", "cell"]) + * @property walletImageUrl Wallet icon URL for TonConnect manifest (required for production) + * @property walletAboutUrl Wallet about/website URL for TonConnect manifest (required for production) + * @property walletUniversalUrl Universal link URL for deep linking (optional, recommended for production) + */ +data class WalletKitBridgeConfig( + val network: String = NetworkConstants.DEFAULT_NETWORK, + val apiUrl: String? = null, + val bridgeUrl: String? = null, + val bridgeName: String? = null, + val tonClientEndpoint: String? = null, + val tonApiUrl: String? = null, + val apiKey: String? = null, + val enablePersistentStorage: Boolean = true, + val appName: String? = null, + val appVersion: String? = null, + val maxMessages: Int = 4, + val signDataTypes: List = listOf( + JsonConstants.VALUE_SIGN_DATA_TEXT, + JsonConstants.VALUE_SIGN_DATA_BINARY, + JsonConstants.VALUE_SIGN_DATA_CELL, + ), + val walletImageUrl: String? = null, + val walletAboutUrl: String? = null, + val walletUniversalUrl: String? = null, +) diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/ConnectRequestEvent.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/ConnectRequestEvent.kt new file mode 100644 index 000000000..020b05f3e --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/ConnectRequestEvent.kt @@ -0,0 +1,47 @@ +package io.ton.walletkit.presentation.event + +import io.ton.walletkit.domain.model.DAppInfo +import kotlinx.serialization.Serializable + +/** + * Represents a connection request event from the bridge. + * Provides the typed representation of the event data for consumers. + */ +@Serializable +data class ConnectRequestEvent( + val id: String, + val from: String? = null, + val preview: Preview? = null, + val request: List? = null, + val dAppInfo: DAppInfo? = null, + var walletAddress: String? = null, +) { + @Serializable + data class Preview( + val manifestURL: String? = null, + val manifest: Manifest? = null, + val permissions: List, + val requestedItems: List? = null, + ) + + @Serializable + data class Manifest( + val name: String? = null, + val description: String? = null, + val url: String? = null, + val iconUrl: String? = null, + ) + + @Serializable + data class ConnectPermission( + val name: String? = null, + val title: String? = null, + val description: String? = null, + ) + + @Serializable + data class Request( + val name: String? = null, + val payload: String? = null, + ) +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/SignDataRequestEvent.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/SignDataRequestEvent.kt new file mode 100644 index 000000000..1923a63a3 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/SignDataRequestEvent.kt @@ -0,0 +1,52 @@ +package io.ton.walletkit.presentation.event + +import io.ton.walletkit.domain.model.DAppInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a sign data request event from the bridge. + * Provides the typed representation of the event data for consumers. + */ +@Serializable +data class SignDataRequestEvent( + val id: String? = null, + val from: String? = null, + val walletAddress: String? = null, + val domain: String? = null, + val sessionId: String? = null, + val messageId: String? = null, + val request: Payload? = null, + val dAppInfo: DAppInfo? = null, + val preview: Preview? = null, +) { + @Serializable + data class Payload( + val network: String? = null, + val from: String? = null, + val type: SignDataType, + val bytes: String? = null, + val schema: String? = null, + val cell: String? = null, + val text: String? = null, + ) + + @Serializable + data class Preview( + val kind: SignDataType, + val content: String? = null, + val schema: String? = null, + ) +} + +@Serializable +enum class SignDataType { + @SerialName("text") + TEXT, + + @SerialName("binary") + BINARY, + + @SerialName("cell") + CELL, +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/TransactionRequestEvent.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/TransactionRequestEvent.kt new file mode 100644 index 000000000..e9a7094bb --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/TransactionRequestEvent.kt @@ -0,0 +1,55 @@ +package io.ton.walletkit.presentation.event + +import io.ton.walletkit.domain.model.DAppInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a transaction request event from the bridge. + * Provides the typed representation of the event data for consumers. + */ +@Serializable +data class TransactionRequestEvent( + val id: String? = null, + val from: String? = null, + val walletAddress: String? = null, + val domain: String? = null, + val sessionId: String? = null, + val messageId: String? = null, + val request: Request? = null, + val dAppInfo: DAppInfo? = null, + val preview: Preview? = null, + val error: String? = null, +) { + @Serializable + data class Request( + val messages: List? = null, + val network: String? = null, + @SerialName("valid_until") + val validUntil: Long? = null, + val from: String? = null, + ) + + @Serializable + data class Message( + val address: String? = null, + val amount: String? = null, + val payload: String? = null, + val stateInit: String? = null, + val mode: Int? = null, + ) + + @Serializable + data class Preview( + val kind: String? = null, + val content: String? = null, + val manifest: Manifest? = null, + ) + + @Serializable + data class Manifest( + val name: String? = null, + val url: String? = null, + val iconUrl: String? = null, + ) +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/WalletKitEvent.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/WalletKitEvent.kt new file mode 100644 index 000000000..b21c6435e --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/event/WalletKitEvent.kt @@ -0,0 +1,50 @@ +package io.ton.walletkit.presentation.event + +import io.ton.walletkit.presentation.request.ConnectRequest +import io.ton.walletkit.presentation.request.SignDataRequest +import io.ton.walletkit.presentation.request.TransactionRequest + +/** + * Represents events from WalletKit bridge using a type-safe sealed hierarchy for all possible events. + */ +sealed class WalletKitEvent { + /** + * A dApp is requesting to connect to a wallet. + * + * @property request Connection request with approve/reject methods + */ + data class ConnectRequestEvent(val request: ConnectRequest) : WalletKitEvent() + + /** + * A dApp is requesting to execute a transaction. + * + * @property request Transaction request with approve/reject methods + */ + data class TransactionRequestEvent(val request: TransactionRequest) : WalletKitEvent() + + /** + * A dApp is requesting to sign arbitrary data. + * + * @property request Sign data request with approve/reject methods + */ + data class SignDataRequestEvent(val request: SignDataRequest) : WalletKitEvent() + + /** + * A session has been disconnected. + * + * @property sessionId ID of the disconnected session + */ + data class DisconnectEvent(val sessionId: String) : WalletKitEvent() + + /** + * Wallet state has changed (balance, transactions, etc.). + * + * @property address Address of the wallet that changed + */ + data class StateChangedEvent(val address: String) : WalletKitEvent() + + /** + * Active sessions list has changed. + */ + data object SessionsChangedEvent : WalletKitEvent() +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/impl/WebViewWalletKitEngine.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/impl/WebViewWalletKitEngine.kt new file mode 100644 index 000000000..68fc8aea3 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/impl/WebViewWalletKitEngine.kt @@ -0,0 +1,1151 @@ +package io.ton.walletkit.presentation.impl + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.util.Log +import android.view.ViewGroup +import android.webkit.JavascriptInterface +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.WebViewAssetLoader +import io.ton.walletkit.data.storage.bridge.BridgeStorageAdapter +import io.ton.walletkit.data.storage.bridge.SecureBridgeStorageAdapter +import io.ton.walletkit.domain.constants.BridgeMethodConstants +import io.ton.walletkit.domain.constants.EventTypeConstants +import io.ton.walletkit.domain.constants.JsonConstants +import io.ton.walletkit.domain.constants.LogConstants +import io.ton.walletkit.domain.constants.MiscConstants +import io.ton.walletkit.domain.constants.NetworkConstants +import io.ton.walletkit.domain.constants.ResponseConstants +import io.ton.walletkit.domain.constants.WebViewConstants +import io.ton.walletkit.domain.model.DAppInfo +import io.ton.walletkit.domain.model.SignDataRequest +import io.ton.walletkit.domain.model.SignDataResult +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.TransactionRequest +import io.ton.walletkit.domain.model.TransactionType +import io.ton.walletkit.domain.model.WalletAccount +import io.ton.walletkit.domain.model.WalletSession +import io.ton.walletkit.domain.model.WalletState +import io.ton.walletkit.presentation.WalletKitBridgeException +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.WalletKitEngineKind +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.event.ConnectRequestEvent +import io.ton.walletkit.presentation.event.SignDataRequestEvent +import io.ton.walletkit.presentation.event.TransactionRequestEvent +import io.ton.walletkit.presentation.event.WalletKitEvent +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import io.ton.walletkit.presentation.request.ConnectRequest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.Closeable +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArraySet + +/** + * WebView-backed WalletKit engine. Hosts the WalletKit bundle inside a hidden WebView and uses the + * established JS bridge to communicate with the Kotlin layer. + * + * **Persistent Storage**: By default, this engine persists wallet and session data + * using secure encrypted storage. Data is automatically restored on app restart. + * Storage can be disabled via config for testing or privacy-focused use cases. + * + * @suppress This is an internal implementation class. Use [WalletKitEngineFactory.create] instead. + */ +internal class WebViewWalletKitEngine( + context: Context, + private val assetPath: String = WebViewConstants.DEFAULT_ASSET_PATH, +) : WalletKitEngine { + override val kind: WalletKitEngineKind = WalletKitEngineKind.WEBVIEW + + private val logTag = WebViewConstants.LOG_TAG_WEBVIEW + private val appContext = context.applicationContext + + // Json instance configured to ignore unknown keys (bridge may send extra fields) + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + // Secure storage adapter for the bridge (conditionally enabled based on config) + private val storageAdapter: BridgeStorageAdapter = SecureBridgeStorageAdapter(appContext) + + // Whether persistent storage is enabled (set during init) + @Volatile private var persistentStorageEnabled: Boolean = true + + private val assetLoader = + WebViewAssetLoader + .Builder() + .setDomain(WebViewConstants.ASSET_LOADER_DOMAIN) + .addPathHandler(WebViewConstants.ASSET_LOADER_PATH, WebViewAssetLoader.AssetsPathHandler(appContext)) + .build() + private val mainHandler = Handler(Looper.getMainLooper()) + private val webView: WebView = WebView(appContext) + private val ready = CompletableDeferred() + private val pending = ConcurrentHashMap>() + private val eventHandlers = CopyOnWriteArraySet() + + @Volatile private var currentNetwork: String = NetworkConstants.DEFAULT_NETWORK + + @Volatile private var apiBaseUrl: String = NetworkConstants.DEFAULT_TESTNET_API_URL + + @Volatile private var tonApiKey: String? = null + + // Auto-initialization state + @Volatile private var isWalletKitInitialized = false + private val walletKitInitMutex = Mutex() + private var pendingInitConfig: WalletKitBridgeConfig? = null + + init { + mainHandler.post { + WebView.setWebContentsDebuggingEnabled(true) + webView.settings.javaScriptEnabled = true + webView.settings.domStorageEnabled = true + webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE + webView.settings.allowFileAccess = true + webView.addJavascriptInterface(JsBinding(), WebViewConstants.JS_INTERFACE_NAME) + webView.webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + val description = error?.description?.toString() ?: ResponseConstants.VALUE_UNKNOWN + val failingUrl = request?.url?.toString() + Log.e(logTag, "${WebViewConstants.ERROR_WEBVIEW_LOAD_PREFIX}$description url=$failingUrl") + if (request?.isForMainFrame == true && !ready.isCompleted) { + ready.completeExceptionally( + WalletKitBridgeException( + "${WebViewConstants.ERROR_BUNDLE_LOAD_FAILED} ($description). ${WebViewConstants.BUILD_INSTRUCTION}", + ), + ) + } + } + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + val url = request?.url ?: return super.shouldInterceptRequest(view, request) + return assetLoader.shouldInterceptRequest(url) + ?: super.shouldInterceptRequest(view, request) + } + } + val safeAssetPath = assetPath.trimStart('/') + webView.loadUrl(WebViewConstants.URL_PREFIX_HTTPS + WebViewConstants.ASSET_LOADER_DOMAIN + WebViewConstants.ASSET_LOADER_PATH + safeAssetPath) + Log.d(logTag, ERROR_WEBVIEW_LOADING_ASSET + assetPath) + } + } + + /** + * Attach the WebView to a parent view so it can be inspected/debugged if needed. + * @suppress Internal debugging method. Not part of public API. + */ + internal fun attachTo(parent: ViewGroup) { + if (webView.parent !== parent) { + (webView.parent as? ViewGroup)?.removeView(webView) + parent.addView(webView) + } + } + + /** + * Get the underlying WebView instance. + * @suppress Internal debugging method. Not part of public API. + */ + internal fun asView(): WebView = webView + + override fun addEventHandler(handler: WalletKitEventHandler): Closeable { + eventHandlers.add(handler) + return Closeable { eventHandlers.remove(handler) } + } + + /** + * Ensures WalletKit is initialized. If not already initialized, performs initialization + * with the provided config or defaults. This is called automatically by all public methods + * that require initialization, enabling auto-init behavior. + * + * @param config Configuration to use for initialization if not already initialized + */ + private suspend fun ensureWalletKitInitialized(config: WalletKitBridgeConfig = WalletKitBridgeConfig()) { + // Fast path: already initialized + if (isWalletKitInitialized) { + return + } + + walletKitInitMutex.withLock { + // Double-check after acquiring lock + if (isWalletKitInitialized) { + return@withLock + } + + Log.d(logTag, ERROR_AUTO_INITIALIZING_WALLETKIT + config.network) + + // Use pending config if init was called explicitly, otherwise use provided config + val effectiveConfig = pendingInitConfig ?: config + pendingInitConfig = null + + try { + performInitialization(effectiveConfig) + isWalletKitInitialized = true + Log.d(logTag, ERROR_WALLETKIT_AUTO_INIT_SUCCESS) + } catch (err: Throwable) { + Log.e(logTag, ERROR_WALLETKIT_AUTO_INIT_FAILED, err) + throw WalletKitBridgeException( + ERROR_FAILED_AUTO_INIT_WALLETKIT + err.message, + ) + } + } + } + + /** + * Performs the actual initialization by calling the JavaScript init method. + */ + private suspend fun performInitialization(config: WalletKitBridgeConfig) { + currentNetwork = config.network + persistentStorageEnabled = config.enablePersistentStorage + + // Only use explicitly provided URLs; JS side handles defaults based on network + val tonClientEndpoint = config.tonClientEndpoint?.ifBlank { null } + ?: config.apiUrl?.ifBlank { null } + apiBaseUrl = config.tonApiUrl?.ifBlank { null } ?: "" + tonApiKey = config.apiKey + + // Get app version from PackageManager + val appVersion = config.appVersion ?: try { + val packageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0) + packageInfo.versionName ?: NetworkConstants.DEFAULT_APP_VERSION + } catch (e: Exception) { + Log.w(logTag, ERROR_FAILED_GET_APP_VERSION, e) + NetworkConstants.DEFAULT_APP_VERSION + } + + // Get app name from config or use application label + val appName = config.appName ?: try { + val applicationInfo = appContext.applicationInfo + val stringId = applicationInfo.labelRes + if (stringId == 0) { + applicationInfo.nonLocalizedLabel?.toString() ?: appContext.packageName + } else { + appContext.getString(stringId) + } + } catch (e: Exception) { + Log.w(logTag, ERROR_FAILED_GET_APP_NAME, e) + appContext.packageName + } + + val payload = + JSONObject().apply { + put(JsonConstants.KEY_NETWORK, config.network) + // Only include URLs if explicitly provided; JS will use defaults otherwise + tonClientEndpoint?.let { put(JsonConstants.KEY_API_URL, it) } + config.tonApiUrl?.let { put(JsonConstants.KEY_TON_API_URL, it) } + config.bridgeUrl?.let { put(JsonConstants.KEY_BRIDGE_URL, it) } + config.bridgeName?.let { put(JsonConstants.KEY_BRIDGE_NAME, it) } + + // Add walletManifest so dApp can recognize the wallet + put( + JsonConstants.KEY_WALLET_MANIFEST, + JSONObject().apply { + put(JsonConstants.KEY_NAME, appName) + put(JsonConstants.KEY_APP_NAME, appName) + put(JsonConstants.KEY_IMAGE_URL, config.walletImageUrl ?: NetworkConstants.DEFAULT_WALLET_IMAGE_URL) + put(JsonConstants.KEY_ABOUT_URL, config.walletAboutUrl ?: NetworkConstants.DEFAULT_WALLET_ABOUT_URL) + config.walletUniversalUrl?.let { put(JsonConstants.KEY_UNIVERSAL_URL, it) } + put( + JsonConstants.KEY_PLATFORMS, + JSONArray().apply { + put(WebViewConstants.PLATFORM_ANDROID) + }, + ) + }, + ) + + // Add deviceInfo with SendTransaction and SignData features + put( + JsonConstants.KEY_DEVICE_INFO, + JSONObject().apply { + put(JsonConstants.KEY_PLATFORM, WebViewConstants.PLATFORM_ANDROID) + put(JsonConstants.KEY_APP_NAME, appName) + put(JsonConstants.KEY_APP_VERSION, appVersion) + put(JsonConstants.KEY_MAX_PROTOCOL_VERSION, NetworkConstants.MAX_PROTOCOL_VERSION) + put( + JsonConstants.KEY_FEATURES, + JSONArray().apply { + // Add SendTransaction feature (detailed form matching iOS) + put( + JSONObject().apply { + put(JsonConstants.KEY_NAME, JsonConstants.FEATURE_SEND_TRANSACTION) + put(JsonConstants.KEY_MAX_MESSAGES, config.maxMessages) + }, + ) + // Add SignData feature with supported types + put( + JSONObject().apply { + put(JsonConstants.KEY_NAME, JsonConstants.FEATURE_SIGN_DATA) + put(JsonConstants.KEY_TYPES, JSONArray(config.signDataTypes)) + }, + ) + }, + ) + }, + ) + + // Note: Persistent storage is controlled by enablePersistentStorage flag + // When disabled, storage operations return immediately without persisting + } + + Log.d( + logTag, + "$ERROR_INITIALIZING_WALLETKIT$persistentStorageEnabled, app: $appName v$appVersion", + ) + call(BridgeMethodConstants.METHOD_INIT, payload) + } + + override suspend fun init(config: WalletKitBridgeConfig) { + // Store config for use during auto-init if this is called before any other method + walletKitInitMutex.withLock { + if (!isWalletKitInitialized) { + pendingInitConfig = config + } + } + + // Ensure initialization happens with this config + ensureWalletKitInitialized(config) + } + + override suspend fun addWalletFromMnemonic( + words: List, + name: String?, + version: String, + network: String?, + ): WalletAccount { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put(JsonConstants.KEY_WORDS, JSONArray(words)) + put(JsonConstants.KEY_VERSION, version) + network?.let { put(JsonConstants.KEY_NETWORK, it) } + name?.let { put(JsonConstants.KEY_NAME, it) } + } + val result = call(BridgeMethodConstants.METHOD_ADD_WALLET_FROM_MNEMONIC, params) + + // Parse the result into WalletAccount + return WalletAccount( + address = result.optString(ResponseConstants.KEY_ADDRESS), + publicKey = result.optNullableString(ResponseConstants.KEY_PUBLIC_KEY), + name = result.optNullableString(JsonConstants.KEY_NAME) ?: name, + version = result.optString(JsonConstants.KEY_VERSION, version), + network = result.optString(JsonConstants.KEY_NETWORK, network ?: currentNetwork), + index = result.optInt(ResponseConstants.KEY_INDEX, 0), + ) + } + + override suspend fun getWallets(): List { + ensureWalletKitInitialized() + val result = call(BridgeMethodConstants.METHOD_GET_WALLETS) + val items = result.optJSONArray(ResponseConstants.KEY_ITEMS) ?: JSONArray() + return buildList(items.length()) { + for (index in 0 until items.length()) { + val entry = items.optJSONObject(index) ?: continue + add( + WalletAccount( + address = entry.optString(ResponseConstants.KEY_ADDRESS), + publicKey = entry.optNullableString(ResponseConstants.KEY_PUBLIC_KEY), + name = entry.optNullableString(JsonConstants.KEY_NAME), + version = entry.optString(JsonConstants.KEY_VERSION, ResponseConstants.VALUE_UNKNOWN), + network = entry.optString(JsonConstants.KEY_NETWORK, currentNetwork), + index = entry.optInt(ResponseConstants.KEY_INDEX, index), + ), + ) + } + } + } + + override suspend fun removeWallet(address: String) { + ensureWalletKitInitialized() + val params = JSONObject().apply { put(ResponseConstants.KEY_ADDRESS, address) } + val result = call(BridgeMethodConstants.METHOD_REMOVE_WALLET, params) + Log.d(logTag, ERROR_REMOVE_WALLET_RESULT + result) + + // Check if removal was successful + val removed = when { + result.has(ResponseConstants.KEY_REMOVED) -> result.optBoolean(ResponseConstants.KEY_REMOVED, false) + result.has(ResponseConstants.KEY_OK) -> result.optBoolean(ResponseConstants.KEY_OK, true) + result.has(ResponseConstants.KEY_VALUE) -> result.optBoolean(ResponseConstants.KEY_VALUE, true) + else -> true + } + + if (!removed) { + throw WalletKitBridgeException(ERROR_FAILED_REMOVE_WALLET + address) + } + } + + override suspend fun getWalletState(address: String): WalletState { + ensureWalletKitInitialized() + val params = JSONObject().apply { put(ResponseConstants.KEY_ADDRESS, address) } + val result = call(BridgeMethodConstants.METHOD_GET_WALLET_STATE, params) + return WalletState( + balance = + when { + result.has(ResponseConstants.KEY_BALANCE) -> result.optString(ResponseConstants.KEY_BALANCE) + result.has(ResponseConstants.KEY_VALUE) -> result.optString(ResponseConstants.KEY_VALUE) + else -> null + }, + transactions = parseTransactions(result.optJSONArray(ResponseConstants.KEY_TRANSACTIONS)), + ) + } + + override suspend fun getRecentTransactions(address: String, limit: Int): List { + ensureWalletKitInitialized() + val params = JSONObject().apply { + put(ResponseConstants.KEY_ADDRESS, address) + put(ResponseConstants.KEY_LIMIT, limit) + } + val result = call(BridgeMethodConstants.METHOD_GET_RECENT_TRANSACTIONS, params) + return parseTransactions(result.optJSONArray(ResponseConstants.KEY_ITEMS)) + } + + /** + * Parse JSONArray of transactions into typed Transaction list. + * Filters out jetton/token transactions, only showing native TON transfers. + */ + private fun parseTransactions(jsonArray: JSONArray?): List { + if (jsonArray == null) return emptyList() + + return buildList(jsonArray.length()) { + for (i in 0 until jsonArray.length()) { + val txJson = jsonArray.optJSONObject(i) ?: continue + + // Get messages + val inMsg = txJson.optJSONObject(ResponseConstants.KEY_IN_MSG) + val outMsgs = txJson.optJSONArray(ResponseConstants.KEY_OUT_MSGS) + + // Filter out jetton/token transactions + // Jetton transactions have op_code in their messages (like 0xf8a7ea5 for transfer) + // or have a message body/payload + val isJettonOrTokenTx = when { + // Check incoming message for jetton markers + inMsg != null -> { + val opCode = inMsg.optString(ResponseConstants.KEY_OP_CODE)?.takeIf { it.isNotEmpty() } + val body = inMsg.optString(ResponseConstants.KEY_BODY)?.takeIf { it.isNotEmpty() } + val message = inMsg.optString(ResponseConstants.KEY_MESSAGE)?.takeIf { it.isNotEmpty() } + // Has op_code or has complex body (not just a comment) + opCode != null || (body != null && body != ResponseConstants.VALUE_EMPTY_CELL_BODY) || + (message != null && message.length > 200) + } + // Check outgoing messages for jetton markers + outMsgs != null && outMsgs.length() > 0 -> { + var hasJettonMarkers = false + for (j in 0 until outMsgs.length()) { + val msg = outMsgs.optJSONObject(j) ?: continue + val opCode = msg.optString(ResponseConstants.KEY_OP_CODE)?.takeIf { it.isNotEmpty() } + val body = msg.optString(ResponseConstants.KEY_BODY)?.takeIf { it.isNotEmpty() } + val message = msg.optString(ResponseConstants.KEY_MESSAGE)?.takeIf { it.isNotEmpty() } + if (opCode != null || (body != null && body != ResponseConstants.VALUE_EMPTY_CELL_BODY) || + (message != null && message.length > 200) + ) { + hasJettonMarkers = true + break + } + } + hasJettonMarkers + } + else -> false + } + + // Skip non-TON transactions + if (isJettonOrTokenTx) { + Log.d(logTag, ERROR_SKIPPING_JETTON_TRANSACTION + txJson.optString(ResponseConstants.KEY_HASH_HEX, ResponseConstants.VALUE_UNKNOWN)) + continue + } + + // Determine transaction type based on incoming/outgoing value + // Check if incoming message has value (meaning we received funds) + val incomingValue = inMsg?.optString(ResponseConstants.KEY_VALUE)?.toLongOrNull() ?: 0L + val hasIncomingValue = incomingValue > 0 + + // Check if we have outgoing messages with value + var outgoingValue = 0L + if (outMsgs != null) { + for (j in 0 until outMsgs.length()) { + val msg = outMsgs.optJSONObject(j) + val value = msg?.optString(ResponseConstants.KEY_VALUE)?.toLongOrNull() ?: 0L + outgoingValue += value + } + } + val hasOutgoingValue = outgoingValue > 0 + + // Transaction is INCOMING if we received value, OUTGOING if we only sent value + // Note: Many incoming transactions also have outgoing messages (fees, change, etc.) + val type = when { + hasIncomingValue -> TransactionType.INCOMING + hasOutgoingValue -> TransactionType.OUTGOING + else -> TransactionType.UNKNOWN + } + + // Get amount based on transaction type (already calculated above) + val amount = when (type) { + TransactionType.INCOMING -> incomingValue.toString() + TransactionType.OUTGOING -> outgoingValue.toString() + else -> "0" + } + + // Get fee from total_fees field + val fee = txJson.optString(ResponseConstants.KEY_TOTAL_FEES)?.takeIf { it.isNotEmpty() } + + // Get comment from messages + val comment = when (type) { + TransactionType.INCOMING -> inMsg?.optString(ResponseConstants.KEY_COMMENT)?.takeIf { it.isNotEmpty() } + TransactionType.OUTGOING -> outMsgs?.optJSONObject(0)?.optString(ResponseConstants.KEY_COMMENT)?.takeIf { it.isNotEmpty() } + else -> null + } + + // Get sender - prefer friendly address + val sender = if (type == TransactionType.INCOMING) { + inMsg?.optString(ResponseConstants.KEY_SOURCE_FRIENDLY)?.takeIf { it.isNotEmpty() } + ?: inMsg?.optString(ResponseConstants.KEY_SOURCE) + } else { + null + } + + // Get recipient - prefer friendly address + val recipient = if (type == TransactionType.OUTGOING) { + outMsgs?.optJSONObject(0)?.let { msg -> + msg.optString(ResponseConstants.KEY_DESTINATION_FRIENDLY)?.takeIf { it.isNotEmpty() } + ?: msg.optString(ResponseConstants.KEY_DESTINATION) + } + } else { + null + } + + // Get hash - prefer hex format + val hash = txJson.optString(ResponseConstants.KEY_HASH_HEX)?.takeIf { it.isNotEmpty() } + ?: txJson.optString(JsonConstants.KEY_HASH, MiscConstants.EMPTY_STRING) + + // Get timestamp - use 'now' field and convert to milliseconds + val timestamp = txJson.optLong(ResponseConstants.KEY_NOW, 0L) * 1000 + + // Get logical time and block sequence number + val lt = txJson.optString(JsonConstants.KEY_LT)?.takeIf { it.isNotEmpty() } + val blockSeqno = txJson.optInt(ResponseConstants.KEY_MC_BLOCK_SEQNO, -1).takeIf { it >= 0 } + + add( + Transaction( + hash = hash, + timestamp = timestamp, + amount = amount, + fee = fee, + comment = comment, + sender = sender, + recipient = recipient, + type = type, + lt = lt, + blockSeqno = blockSeqno, + ), + ) + } + } + } + + override suspend fun handleTonConnectUrl(url: String) { + ensureWalletKitInitialized() + val params = JSONObject().apply { put(ResponseConstants.KEY_URL, url) } + call(BridgeMethodConstants.METHOD_HANDLE_TON_CONNECT_URL, params) + } + + override suspend fun sendTransaction( + walletAddress: String, + recipient: String, + amount: String, + comment: String?, + ) { + ensureWalletKitInitialized() + val params = + JSONObject().apply { + put(ResponseConstants.KEY_WALLET_ADDRESS, walletAddress) + put(ResponseConstants.KEY_TO_ADDRESS, recipient) + put(ResponseConstants.KEY_AMOUNT, amount) + if (!comment.isNullOrBlank()) { + put(ResponseConstants.KEY_COMMENT, comment) + } + } + call(BridgeMethodConstants.METHOD_SEND_TRANSACTION, params) + } + + override suspend fun approveConnect(event: ConnectRequestEvent) { + ensureWalletKitInitialized() + val eventJson = JSONObject(json.encodeToString(event)) + val params = + JSONObject().apply { + put(ResponseConstants.KEY_EVENT, eventJson) + put(ResponseConstants.KEY_WALLET_ADDRESS, event.walletAddress ?: throw WalletKitBridgeException(ERROR_WALLET_ADDRESS_REQUIRED)) + } + call(BridgeMethodConstants.METHOD_APPROVE_CONNECT_REQUEST, params) + } + + override suspend fun rejectConnect( + event: ConnectRequestEvent, + reason: String?, + ) { + ensureWalletKitInitialized() + val eventJson = JSONObject(json.encodeToString(event)) + val params = + JSONObject().apply { + put(ResponseConstants.KEY_EVENT, eventJson) + reason?.let { put(ResponseConstants.KEY_REASON, it) } + } + call(BridgeMethodConstants.METHOD_REJECT_CONNECT_REQUEST, params) + } + + override suspend fun approveTransaction(event: TransactionRequestEvent) { + ensureWalletKitInitialized() + val eventJson = JSONObject(json.encodeToString(event)) + val params = JSONObject().apply { put(ResponseConstants.KEY_EVENT, eventJson) } + call(BridgeMethodConstants.METHOD_APPROVE_TRANSACTION_REQUEST, params) + } + + override suspend fun rejectTransaction( + event: TransactionRequestEvent, + reason: String?, + ) { + ensureWalletKitInitialized() + val eventJson = JSONObject(json.encodeToString(event)) + val params = + JSONObject().apply { + put(ResponseConstants.KEY_EVENT, eventJson) + reason?.let { put(ResponseConstants.KEY_REASON, it) } + } + call(BridgeMethodConstants.METHOD_REJECT_TRANSACTION_REQUEST, params) + } + + override suspend fun approveSignData(event: SignDataRequestEvent): SignDataResult { + ensureWalletKitInitialized() + val eventJson = JSONObject(json.encodeToString(event)) + val params = JSONObject().apply { put(ResponseConstants.KEY_EVENT, eventJson) } + val result = call(BridgeMethodConstants.METHOD_APPROVE_SIGN_DATA_REQUEST, params) + + Log.d(logTag, ERROR_APPROVE_SIGN_DATA_RAW_RESULT + result) + + // Extract signature from the response + // The result might be nested in a 'result' object or directly accessible + val signature = result.optNullableString(ResponseConstants.KEY_SIGNATURE) + ?: result.optJSONObject(ResponseConstants.KEY_RESULT)?.optNullableString(ResponseConstants.KEY_SIGNATURE) + ?: result.optJSONObject(ResponseConstants.KEY_DATA)?.optNullableString(ResponseConstants.KEY_SIGNATURE) + + if (signature.isNullOrEmpty()) { + throw WalletKitBridgeException(ERROR_NO_SIGNATURE_IN_RESPONSE + result) + } + + return SignDataResult(signature = signature) + } + + override suspend fun rejectSignData( + event: SignDataRequestEvent, + reason: String?, + ) { + ensureWalletKitInitialized() + val eventJson = JSONObject(json.encodeToString(event)) + val params = + JSONObject().apply { + put(ResponseConstants.KEY_EVENT, eventJson) + reason?.let { put(ResponseConstants.KEY_REASON, it) } + } + call(BridgeMethodConstants.METHOD_REJECT_SIGN_DATA_REQUEST, params) + } + + override suspend fun listSessions(): List { + ensureWalletKitInitialized() + val result = call(BridgeMethodConstants.METHOD_LIST_SESSIONS) + val items = result.optJSONArray(ResponseConstants.KEY_ITEMS) ?: JSONArray() + return buildList(items.length()) { + for (index in 0 until items.length()) { + val entry = items.optJSONObject(index) ?: continue + add( + WalletSession( + sessionId = entry.optString(ResponseConstants.KEY_SESSION_ID), + dAppName = entry.optString(ResponseConstants.KEY_DAPP_NAME), + walletAddress = entry.optString(ResponseConstants.KEY_WALLET_ADDRESS), + dAppUrl = entry.optNullableString(JsonConstants.KEY_DAPP_URL), + manifestUrl = entry.optNullableString(JsonConstants.KEY_MANIFEST_URL), + iconUrl = entry.optNullableString(JsonConstants.KEY_ICON_URL), + createdAtIso = entry.optNullableString(ResponseConstants.KEY_CREATED_AT), + lastActivityIso = entry.optNullableString(ResponseConstants.KEY_LAST_ACTIVITY), + ), + ) + } + } + } + + override suspend fun disconnectSession(sessionId: String?) { + ensureWalletKitInitialized() + val params = JSONObject() + sessionId?.let { params.put(ResponseConstants.KEY_SESSION_ID, it) } + call(BridgeMethodConstants.METHOD_DISCONNECT_SESSION, if (params.length() == 0) null else params) + } + + override suspend fun destroy() { + withContext(Dispatchers.Main) { + (webView.parent as? ViewGroup)?.removeView(webView) + webView.removeJavascriptInterface(WebViewConstants.JS_INTERFACE_NAME) + webView.stopLoading() + webView.destroy() + } + } + + private suspend fun call( + method: String, + params: JSONObject? = null, + ): JSONObject { + ready.await() + val callId = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[callId] = deferred + + val payload = params?.toString() + val idLiteral = JSONObject.quote(callId) + val methodLiteral = JSONObject.quote(method) + val script = + if (payload == null) { + WebViewConstants.JS_FUNCTION_WALLETKIT_CALL + "(" + idLiteral + "," + methodLiteral + "," + WebViewConstants.JS_NULL + ")" + } else { + val payloadBase64 = Base64.encodeToString(payload.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + val payloadLiteral = JSONObject.quote(payloadBase64) + WebViewConstants.JS_FUNCTION_WALLETKIT_CALL + "(" + idLiteral + "," + methodLiteral + ", " + WebViewConstants.JS_FUNCTION_ATOB + "(" + payloadLiteral + "))" + } + + withContext(Dispatchers.Main) { + webView.evaluateJavascript(script, null) + } + + val response = deferred.await() + Log.d(logTag, ERROR_CALL_COMPLETED + method + ERROR_COMPLETED_SUFFIX) + return response.result + } + + private fun handleResponse( + id: String, + response: JSONObject, + ) { + val deferred = pending.remove(id) ?: return + val error = response.optJSONObject(ResponseConstants.KEY_ERROR) + if (error != null) { + val message = error.optString(ResponseConstants.KEY_MESSAGE, ResponseConstants.ERROR_MESSAGE_DEFAULT) + Log.e(logTag, ERROR_CALL_FAILED + id + ERROR_FAILED_SUFFIX + message) + deferred.completeExceptionally(WalletKitBridgeException(message)) + return + } + val result = response.opt(ResponseConstants.KEY_RESULT) + val payload = + when (result) { + is JSONObject -> result + is JSONArray -> JSONObject().put(ResponseConstants.KEY_ITEMS, result) + null -> JSONObject() + else -> JSONObject().put(ResponseConstants.KEY_VALUE, result) + } + deferred.complete(BridgeResponse(payload)) + } + + private fun handleEvent(event: JSONObject) { + val type = event.optString(JsonConstants.KEY_TYPE, EventTypeConstants.EVENT_TYPE_UNKNOWN) + val data = event.optJSONObject(ResponseConstants.KEY_DATA) ?: JSONObject() + Log.d(logTag, ERROR_EVENT_PREFIX + type + ERROR_BRACKET_SUFFIX) + + // Typed event handlers (sealed class) + val typedEvent = parseTypedEvent(type, data, event) + if (typedEvent != null) { + eventHandlers.forEach { handler -> + mainHandler.post { handler.handleEvent(typedEvent) } + } + } + } + + private fun parseTypedEvent(type: String, data: JSONObject, raw: JSONObject): WalletKitEvent? { + return when (type) { + EventTypeConstants.EVENT_CONNECT_REQUEST -> { + try { + // Deserialize JSON into typed event + val event = json.decodeFromString(data.toString()) + val requestId = event.id + val dAppInfo = parseDAppInfo(data) // Keep existing parser for now + val permissions = event.preview?.permissions ?: emptyList() + val request = ConnectRequest( + requestId = requestId, + dAppInfo = dAppInfo, + permissions = permissions, + event = event, + engine = this, + ) + WalletKitEvent.ConnectRequestEvent(request) + } catch (e: Exception) { + Log.e(logTag, ERROR_FAILED_PARSE_CONNECT_REQUEST, e) + null + } + } + + EventTypeConstants.EVENT_TRANSACTION_REQUEST -> { + try { + // Deserialize JSON into typed event + val event = json.decodeFromString(data.toString()) + val requestId = event.id ?: return null + val dAppInfo = parseDAppInfo(data) // Keep existing parser for now + val txRequest = parseTransactionRequest(data) // Keep existing parser for now + + // Extract preview data if available + val preview = data.optJSONObject(ResponseConstants.KEY_PREVIEW)?.toString() + + val request = io.ton.walletkit.presentation.request.TransactionRequest( + requestId = requestId, + dAppInfo = dAppInfo, + request = txRequest, + preview = preview, + event = event, + engine = this, + ) + WalletKitEvent.TransactionRequestEvent(request) + } catch (e: Exception) { + Log.e(logTag, ERROR_FAILED_PARSE_TRANSACTION_REQUEST, e) + null + } + } + + EventTypeConstants.EVENT_SIGN_DATA_REQUEST -> { + try { + // Deserialize JSON into typed event + val event = json.decodeFromString(data.toString()) + val requestId = event.id ?: return null + val dAppInfo = parseDAppInfo(data) // Keep existing parser for now + val signRequest = parseSignDataRequest(data) // Keep existing parser for now + val request = io.ton.walletkit.presentation.request.SignDataRequest( + requestId = requestId, + dAppInfo = dAppInfo, + request = signRequest, + event = event, + engine = this, + ) + WalletKitEvent.SignDataRequestEvent(request) + } catch (e: Exception) { + Log.e(logTag, ERROR_FAILED_PARSE_SIGN_DATA_REQUEST, e) + null + } + } + + EventTypeConstants.EVENT_DISCONNECT -> { + val sessionId = data.optNullableString(ResponseConstants.KEY_SESSION_ID) + ?: data.optNullableString(JsonConstants.KEY_ID) + ?: return null + WalletKitEvent.DisconnectEvent(sessionId) + } + + EventTypeConstants.EVENT_STATE_CHANGED, EventTypeConstants.EVENT_WALLET_STATE_CHANGED -> { + val address = data.optNullableString(ResponseConstants.KEY_ADDRESS) + ?: data.optJSONObject(ResponseConstants.KEY_WALLET)?.optNullableString(ResponseConstants.KEY_ADDRESS) + ?: return null + WalletKitEvent.StateChangedEvent(address) + } + + EventTypeConstants.EVENT_SESSIONS_CHANGED -> { + WalletKitEvent.SessionsChangedEvent + } + + else -> null // Unknown event type + } + } + + private fun parseDAppInfo(data: JSONObject): DAppInfo? { + // Try to get dApp name from multiple sources + val dAppName = data.optNullableString(ResponseConstants.KEY_DAPP_NAME) + ?: data.optJSONObject(ResponseConstants.KEY_MANIFEST)?.optNullableString(JsonConstants.KEY_NAME) + ?: data.optJSONObject(ResponseConstants.KEY_PREVIEW)?.optJSONObject(ResponseConstants.KEY_MANIFEST)?.optNullableString(JsonConstants.KEY_NAME) + + // Try to get URLs from multiple sources + val manifest = data.optJSONObject(ResponseConstants.KEY_PREVIEW)?.optJSONObject(ResponseConstants.KEY_MANIFEST) + ?: data.optJSONObject(ResponseConstants.KEY_MANIFEST) + + val dAppUrl = data.optNullableString(ResponseConstants.KEY_DAPP_URL_ALT) + ?: manifest?.optNullableString(ResponseConstants.KEY_URL) + ?: "" + + val iconUrl = data.optNullableString(ResponseConstants.KEY_DAPP_ICON_URL) + ?: manifest?.optNullableString(ResponseConstants.KEY_ICON_URL_ALT) + + val manifestUrl = data.optNullableString(ResponseConstants.KEY_MANIFEST_URL_ALT) + ?: manifest?.optNullableString(ResponseConstants.KEY_URL) + + // Only return null if we have absolutely no dApp information + if (dAppName == null && dAppUrl.isEmpty()) { + return null + } + + return DAppInfo( + name = dAppName ?: dAppUrl.takeIf { it.isNotEmpty() } ?: ResponseConstants.VALUE_UNKNOWN_DAPP, + url = dAppUrl, + iconUrl = iconUrl, + manifestUrl = manifestUrl, + ) + } + + private fun parsePermissions(data: JSONObject): List { + val permissions = data.optJSONArray(ResponseConstants.KEY_PERMISSIONS) ?: return emptyList() + return List(permissions.length()) { i -> + permissions.optString(i) + } + } + + private fun parseTransactionRequest(data: JSONObject): TransactionRequest { + // Check if data is nested under "request" field + val requestData = data.optJSONObject(ResponseConstants.KEY_REQUEST) ?: data + + // Try to parse from messages array first (TON Connect format) + val messages = requestData.optJSONArray(ResponseConstants.KEY_MESSAGES) + if (messages != null && messages.length() > 0) { + val firstMessage = messages.optJSONObject(0) + if (firstMessage != null) { + return TransactionRequest( + recipient = firstMessage.optNullableString(ResponseConstants.KEY_ADDRESS) + ?: firstMessage.optNullableString(ResponseConstants.KEY_TO) + ?: "", + amount = firstMessage.optNullableString(ResponseConstants.KEY_AMOUNT) + ?: firstMessage.optNullableString(ResponseConstants.KEY_VALUE) + ?: "0", + comment = firstMessage.optNullableString(ResponseConstants.KEY_COMMENT) + ?: firstMessage.optNullableString(ResponseConstants.KEY_TEXT), + payload = firstMessage.optNullableString(ResponseConstants.KEY_PAYLOAD), + ) + } + } + + // Fallback to direct fields (legacy format or direct send) + return TransactionRequest( + recipient = requestData.optNullableString(ResponseConstants.KEY_TO) ?: requestData.optNullableString(ResponseConstants.KEY_RECIPIENT) ?: "", + amount = requestData.optNullableString(ResponseConstants.KEY_AMOUNT) ?: requestData.optNullableString(ResponseConstants.KEY_VALUE) ?: "0", + comment = requestData.optNullableString(ResponseConstants.KEY_COMMENT) ?: requestData.optNullableString(ResponseConstants.KEY_TEXT), + payload = requestData.optNullableString(ResponseConstants.KEY_PAYLOAD), + ) + } + + private fun parseSignDataRequest(data: JSONObject): SignDataRequest { + // Parse params array - params[0] contains stringified JSON with schema_crc and payload + var payload = data.optNullableString(ResponseConstants.KEY_PAYLOAD) ?: data.optNullableString(ResponseConstants.KEY_DATA) ?: "" + var schema: String? = data.optNullableString(ResponseConstants.KEY_SCHEMA) + + // Check if params array exists (newer format from bridge) + val paramsArray = data.optJSONArray(ResponseConstants.KEY_PARAMS) + if (paramsArray != null && paramsArray.length() > 0) { + val paramsString = paramsArray.optString(0) + if (paramsString.isNotEmpty()) { + try { + val paramsObj = JSONObject(paramsString) + payload = paramsObj.optNullableString(ResponseConstants.KEY_PAYLOAD) ?: payload + + // Convert schema_crc to human-readable schema type + val schemaCrc = paramsObj.optInt(ResponseConstants.KEY_SCHEMA_CRC, -1) + schema = when (schemaCrc) { + 0 -> ResponseConstants.VALUE_SCHEMA_TEXT + 1 -> ResponseConstants.VALUE_SCHEMA_BINARY + 2 -> ResponseConstants.VALUE_SCHEMA_CELL + else -> schema + } + } catch (e: Exception) { + Log.e(logTag, ERROR_FAILED_PARSE_SIGN_DATA_PARAMS, e) + } + } + } + + return SignDataRequest( + payload = payload, + schema = schema, + ) + } + + private fun handleReady(payload: JSONObject) { + payload.optNullableString(ResponseConstants.KEY_NETWORK)?.let { currentNetwork = it } + payload.optNullableString(ResponseConstants.KEY_TON_API_URL)?.let { apiBaseUrl = it } + if (!ready.isCompleted) { + Log.d(logTag, LogConstants.MSG_BRIDGE_READY) + ready.complete(Unit) + } + val data = JSONObject() + val keys = payload.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key == ResponseConstants.KEY_KIND) continue + if (payload.isNull(key)) { + data.put(key, JSONObject.NULL) + } else { + data.put(key, payload.get(key)) + } + } + val readyEvent = JSONObject().apply { + put(ResponseConstants.KEY_TYPE, ResponseConstants.VALUE_KIND_READY) + put(ResponseConstants.KEY_DATA, data) + } + handleEvent(readyEvent) + } + + private fun JSONObject.optNullableString(key: String): String? { + val value = opt(key) + return when (value) { + null, JSONObject.NULL -> null + else -> value.toString() + } + } + + private inner class JsBinding { + @JavascriptInterface + fun postMessage(json: String) { + try { + val payload = JSONObject(json) + when (payload.optString(ResponseConstants.KEY_KIND)) { + ResponseConstants.VALUE_KIND_READY -> handleReady(payload) + ResponseConstants.VALUE_KIND_EVENT -> payload.optJSONObject(ResponseConstants.KEY_EVENT)?.let { handleEvent(it) } + ResponseConstants.VALUE_KIND_RESPONSE -> handleResponse(payload.optString(ResponseConstants.KEY_ID), payload) + } + } catch (err: JSONException) { + Log.e(logTag, LogConstants.MSG_MALFORMED_PAYLOAD, err) + pending.values.forEach { deferred -> + if (!deferred.isCompleted) { + deferred.completeExceptionally(WalletKitBridgeException("${LogConstants.ERROR_MALFORMED_PAYLOAD_PREFIX}${err.message}")) + } + } + } + } + + /** + * Storage adapter methods called by JavaScript bundle to persist data. + * These methods enable the JS bundle to use Android secure storage instead of + * ephemeral WebView LocalStorage. + * + * If persistent storage is disabled, these methods become no-ops (return null/empty). + */ + @JavascriptInterface + fun storageGet(key: String): String? { + if (!persistentStorageEnabled) { + return null // Return null when storage is disabled + } + + return try { + // Note: This is synchronous from JS perspective but async in Kotlin + // We use runBlocking here as JavascriptInterface requires synchronous return + runBlocking { + storageAdapter.get(key) + } + } catch (e: Exception) { + Log.e(logTag, "${LogConstants.MSG_STORAGE_GET_FAILED}$key", e) + null + } + } + + @JavascriptInterface + fun storageSet( + key: String, + value: String, + ) { + if (!persistentStorageEnabled) { + return // No-op when storage is disabled + } + + try { + runBlocking { + storageAdapter.set(key, value) + } + } catch (e: Exception) { + Log.e(logTag, "${LogConstants.MSG_STORAGE_SET_FAILED}$key", e) + } + } + + @JavascriptInterface + fun storageRemove(key: String) { + if (!persistentStorageEnabled) { + return // No-op when storage is disabled + } + + try { + runBlocking { + storageAdapter.remove(key) + } + } catch (e: Exception) { + Log.e(logTag, "${LogConstants.MSG_STORAGE_REMOVE_FAILED}$key", e) + } + } + + @JavascriptInterface + fun storageClear() { + if (!persistentStorageEnabled) { + return // No-op when storage is disabled + } + + try { + runBlocking { + storageAdapter.clear() + } + } catch (e: Exception) { + Log.e(logTag, LogConstants.MSG_STORAGE_CLEAR_FAILED, e) + } + } + } + + override suspend fun injectSignDataRequest(requestData: JSONObject): JSONObject { + ensureWalletKitInitialized() + return call(BridgeMethodConstants.METHOD_INJECT_SIGN_DATA_REQUEST, requestData) + } + + private data class BridgeResponse( + val result: JSONObject, + ) + + companion object { + // WebView and Initialization Errors + const val ERROR_WEBVIEW_LOADING_ASSET = "WebView bridge loading asset: " + const val ERROR_AUTO_INITIALIZING_WALLETKIT = "Auto-initializing WalletKit with config: network=" + const val ERROR_WALLETKIT_AUTO_INIT_SUCCESS = "WalletKit auto-initialization completed successfully" + const val ERROR_WALLETKIT_AUTO_INIT_FAILED = "WalletKit auto-initialization failed" + const val ERROR_FAILED_AUTO_INIT_WALLETKIT = "Failed to auto-initialize WalletKit: " + const val ERROR_FAILED_GET_APP_VERSION = "Failed to get app version, using default" + const val ERROR_FAILED_GET_APP_NAME = "Failed to get app name, using package name" + const val ERROR_INITIALIZING_WALLETKIT = "Initializing WalletKit with persistent storage: " + + // Wallet Operation Errors + const val ERROR_REMOVE_WALLET_RESULT = "removeWallet result: " + const val ERROR_FAILED_REMOVE_WALLET = "Failed to remove wallet: " + const val ERROR_WALLET_ADDRESS_REQUIRED = "walletAddress is required for connect approval" + + // Transaction Errors + const val ERROR_SKIPPING_JETTON_TRANSACTION = "Skipping jetton/token transaction: " + + // Sign Data Errors + const val ERROR_APPROVE_SIGN_DATA_RAW_RESULT = "approveSignData raw result: " + const val ERROR_NO_SIGNATURE_IN_RESPONSE = "No signature in approveSignData response: " + + // Event Parsing Errors + const val ERROR_FAILED_PARSE_CONNECT_REQUEST = "Failed to parse ConnectRequestEvent" + const val ERROR_FAILED_PARSE_TRANSACTION_REQUEST = "Failed to parse TransactionRequestEvent" + const val ERROR_FAILED_PARSE_SIGN_DATA_REQUEST = "Failed to parse SignDataRequestEvent" + const val ERROR_FAILED_PARSE_SIGN_DATA_PARAMS = "Failed to parse params for sign data" + + // Bridge Call Errors + const val ERROR_CALL_COMPLETED = "call[" + const val ERROR_CALL_FAILED = "call[" + const val ERROR_EVENT_PREFIX = "event[" + const val ERROR_BRACKET_SUFFIX = "] " + const val ERROR_COMPLETED_SUFFIX = "] completed" + const val ERROR_FAILED_SUFFIX = "] failed: " + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/listener/WalletKitEventHandler.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/listener/WalletKitEventHandler.kt new file mode 100644 index 000000000..fd037a34f --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/listener/WalletKitEventHandler.kt @@ -0,0 +1,50 @@ +package io.ton.walletkit.presentation.listener + +import io.ton.walletkit.presentation.event.WalletKitEvent + +/** + * Typed event handler for WalletKit bridge events. + * Provides a single method with sealed event types for exhaustive handling. + * + * Offers sealed classes and exhaustive when() expressions. + * + * Example usage: + * ```kotlin + * class MyHandler : WalletKitEventHandler { + * override fun handleEvent(event: WalletKitEvent) { + * when (event) { + * is WalletKitEvent.ConnectRequestEvent -> { + * // Handle connection + * event.request.approve(walletAddress) + * } + * is WalletKitEvent.TransactionRequestEvent -> { + * // Handle transaction + * event.request.approve() + * } + * is WalletKitEvent.SignDataRequestEvent -> { + * // Handle signing + * val result = event.request.approve() + * } + * is WalletKitEvent.DisconnectEvent -> { + * // Handle disconnect + * } + * is WalletKitEvent.StateChangedEvent -> { + * // Refresh UI + * } + * is WalletKitEvent.SessionsChangedEvent -> { + * // Refresh sessions + * } + * } + * } + * } + * ``` + */ +interface WalletKitEventHandler { + /** + * Handle a WalletKit event. + * Use when() expression for exhaustive handling of all event types. + * + * @param event The event to handle + */ + fun handleEvent(event: WalletKitEvent) +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/ConnectRequest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/ConnectRequest.kt new file mode 100644 index 000000000..ee23e6f08 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/ConnectRequest.kt @@ -0,0 +1,43 @@ +package io.ton.walletkit.presentation.request + +import io.ton.walletkit.domain.model.DAppInfo +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.event.ConnectRequestEvent + +/** + * Represents a connection request from a dApp. + * Encapsulates both request data and approval/rejection actions. + * + * @property requestId Unique identifier for this request + * @property dAppInfo Information about the requesting dApp + * @property permissions List of requested permissions + * @property event Typed event data from the bridge + */ +class ConnectRequest internal constructor( + val requestId: String, + val dAppInfo: DAppInfo?, + val permissions: List, + private val event: ConnectRequestEvent, + private val engine: WalletKitEngine, +) { + /** + * Approve this connection request with the specified wallet. + * + * @param walletAddress Address of the wallet to connect with + * @throws io.ton.walletkit.presentation.WalletKitBridgeException if approval fails + */ + suspend fun approve(walletAddress: String) { + val eventWithWallet = event.copy(walletAddress = walletAddress) + engine.approveConnect(eventWithWallet) + } + + /** + * Reject this connection request. + * + * @param reason Optional reason for rejection + * @throws io.ton.walletkit.presentation.WalletKitBridgeException if rejection fails + */ + suspend fun reject(reason: String? = null) { + engine.rejectConnect(event, reason) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/SignDataRequest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/SignDataRequest.kt new file mode 100644 index 000000000..008fcb0b7 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/SignDataRequest.kt @@ -0,0 +1,42 @@ +package io.ton.walletkit.presentation.request + +import io.ton.walletkit.domain.model.DAppInfo +import io.ton.walletkit.domain.model.SignDataResult +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.event.SignDataRequestEvent +import io.ton.walletkit.domain.model.SignDataRequest as SignDataRequestData + +/** + * Represents a data signing request from a dApp. + * Encapsulates both request data and approval/rejection actions. + * + * @property requestId Unique identifier for this request + * @property dAppInfo Information about the requesting dApp + * @property request Sign data request details (payload, etc.) + * @property event Typed event data from the bridge + */ +class SignDataRequest internal constructor( + val requestId: String, + val dAppInfo: DAppInfo?, + val request: SignDataRequestData, + val event: SignDataRequestEvent, + private val engine: WalletKitEngine, +) { + /** + * Approve and sign this data signing request. + * + * @return Signature result containing the base64-encoded signature + * @throws WalletKitBridgeException if approval or signing fails + */ + suspend fun approve(): SignDataResult = engine.approveSignData(event) + + /** + * Reject this data signing request. + * + * @param reason Optional reason for rejection + * @throws WalletKitBridgeException if rejection fails + */ + suspend fun reject(reason: String? = null) { + engine.rejectSignData(event, reason) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/TransactionRequest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/TransactionRequest.kt new file mode 100644 index 000000000..3a0232f5a --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/main/java/io/ton/walletkit/presentation/request/TransactionRequest.kt @@ -0,0 +1,44 @@ +package io.ton.walletkit.presentation.request + +import io.ton.walletkit.domain.model.DAppInfo +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.event.TransactionRequestEvent +import io.ton.walletkit.domain.model.TransactionRequest as TransactionRequestData + +/** + * Represents a transaction request from a dApp. + * Encapsulates both request data and approval/rejection actions. + * + * @property requestId Unique identifier for this request + * @property dAppInfo Information about the requesting dApp + * @property request Transaction request details (recipient, amount, etc.) + * @property preview Optional preview data as JSON string + * @property event Typed event data from the bridge + */ +class TransactionRequest internal constructor( + val requestId: String, + val dAppInfo: DAppInfo?, + val request: TransactionRequestData, + val preview: String? = null, + private val event: TransactionRequestEvent, + private val engine: WalletKitEngine, +) { + /** + * Approve this transaction request. + * + * @throws WalletKitBridgeException if approval fails + */ + suspend fun approve() { + engine.approveTransaction(event) + } + + /** + * Reject this transaction request. + * + * @param reason Optional reason for rejection + * @throws WalletKitBridgeException if rejection fails + */ + suspend fun reject(reason: String? = null) { + engine.rejectTransaction(event, reason) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/ConfigTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/ConfigTest.kt new file mode 100644 index 000000000..67758bd16 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/ConfigTest.kt @@ -0,0 +1,61 @@ +package io.ton.walletkit.bridge + +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for WalletKitBridgeConfig. + */ +class ConfigTest { + + @Test + fun `default config has testnet network`() { + val config = WalletKitBridgeConfig() + assertEquals("testnet", config.network) + } + + @Test + fun `config has default values for optional URLs`() { + val config = WalletKitBridgeConfig() + assertNotNull(config) + // URLs are optional and may be null or have defaults + } + + @Test + fun `config supports mainnet`() { + val config = WalletKitBridgeConfig(network = "mainnet") + assertEquals("mainnet", config.network) + } + + @Test + fun `config is a data class with copy`() { + val config = WalletKitBridgeConfig(network = "testnet") + val modified = config.copy(network = "mainnet") + + assertEquals("mainnet", modified.network) + assertEquals("testnet", config.network) // Original unchanged + } + + @Test + fun `config supports custom URLs`() { + val config = WalletKitBridgeConfig( + tonApiUrl = "https://custom.api", + bridgeUrl = "https://custom.bridge", + ) + + assertEquals("https://custom.api", config.tonApiUrl) + assertEquals("https://custom.bridge", config.bridgeUrl) + } + + @Test + fun `config supports persistence toggle`() { + val withStorage = WalletKitBridgeConfig(enablePersistentStorage = true) + val withoutStorage = WalletKitBridgeConfig(enablePersistentStorage = false) + + assertTrue(withStorage.enablePersistentStorage) + assertTrue(!withoutStorage.enablePersistentStorage) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/ModelTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/ModelTest.kt new file mode 100644 index 000000000..0d507a657 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/ModelTest.kt @@ -0,0 +1,82 @@ +package io.ton.walletkit.bridge + +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.TransactionType +import io.ton.walletkit.domain.model.WalletAccount +import io.ton.walletkit.domain.model.WalletState +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Tests for wallet data models. + */ +class ModelTest { + + @Test + fun `WalletAccount has required fields`() { + val wallet = WalletAccount( + address = "EQDexample...", + publicKey = "pubkey", + name = "My Wallet", + version = "v5r1", + network = "mainnet", + index = 0, + ) + + assertEquals("EQDexample...", wallet.address) + assertEquals("v5r1", wallet.version) + } + + @Test + fun `WalletState contains balance and transactions`() { + val tx = Transaction( + hash = "hash123", + timestamp = 1000L, + amount = "100", + type = TransactionType.INCOMING, + ) + + val state = WalletState( + balance = "5000000000", + transactions = listOf(tx), + ) + + assertEquals("5000000000", state.balance) + assertEquals(1, state.transactions.size) + } + + @Test + fun `Transaction supports different types`() { + val incoming = Transaction( + hash = "1", + timestamp = 100L, + amount = "50", + type = TransactionType.INCOMING, + ) + + val outgoing = Transaction( + hash = "2", + timestamp = 200L, + amount = "30", + type = TransactionType.OUTGOING, + ) + + assertEquals(TransactionType.INCOMING, incoming.type) + assertEquals(TransactionType.OUTGOING, outgoing.type) + } + + @Test + fun `Transaction optional fields can be null`() { + val tx = Transaction( + hash = "hash", + timestamp = 1000L, + amount = "100", + ) + + assertNotNull(tx.hash) + assertNull(tx.fee) + assertNull(tx.comment) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WalletKitEngineFactoryTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WalletKitEngineFactoryTest.kt new file mode 100644 index 000000000..45734a109 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WalletKitEngineFactoryTest.kt @@ -0,0 +1,193 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.ton.walletkit.presentation.WalletKitEngine +import io.ton.walletkit.presentation.WalletKitEngineFactory +import io.ton.walletkit.presentation.WalletKitEngineKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [WalletKitEngineFactory] to increase coverage for io.ton.walletkit.presentation package. + */ +@RunWith(RobolectricTestRunner::class) +class WalletKitEngineFactoryTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `create with default kind returns WebView engine`() { + val engine = WalletKitEngineFactory.create(context) + assertNotNull(engine) + assertEquals(WalletKitEngineKind.WEBVIEW, engine.kind) + } + + @Test + fun `create with explicit WEBVIEW kind returns WebView engine`() { + val engine = WalletKitEngineFactory.create(context, WalletKitEngineKind.WEBVIEW) + assertNotNull(engine) + assertEquals(WalletKitEngineKind.WEBVIEW, engine.kind) + } + + @Test + fun `create with QUICKJS kind throws in webview variant`() { + try { + @Suppress("DEPRECATION") + WalletKitEngineFactory.create(context, WalletKitEngineKind.QUICKJS) + } catch (e: IllegalStateException) { + assertTrue(e.message?.contains("QuickJS engine is not available") == true) + } + } + + @Test + fun `create multiple engines returns separate instances`() { + val engine1 = WalletKitEngineFactory.create(context) + val engine2 = WalletKitEngineFactory.create(context) + assertTrue(engine1 !== engine2) + } + + @Test + fun `isAvailable returns true for WEBVIEW`() { + val available = WalletKitEngineFactory.isAvailable(WalletKitEngineKind.WEBVIEW) + assertTrue(available) + } + + @Test + fun `isAvailable checks QUICKJS availability`() { + @Suppress("DEPRECATION") + val available = WalletKitEngineFactory.isAvailable(WalletKitEngineKind.QUICKJS) + if (!available) { + assertFalse(available) + } + } + + @Test + fun `isAvailable checks all enum values`() { + for (kind in WalletKitEngineKind.entries) { + val available = WalletKitEngineFactory.isAvailable(kind) + when (kind) { + WalletKitEngineKind.WEBVIEW -> assertTrue(available) + @Suppress("DEPRECATION") + WalletKitEngineKind.QUICKJS -> assertNotNull(available) + } + } + } + + @Test + fun `WalletKitEngineKind enum values are accessible`() { + val values = WalletKitEngineKind.entries.toTypedArray() + assertEquals(2, values.size) + assertTrue(values.contains(WalletKitEngineKind.WEBVIEW)) + @Suppress("DEPRECATION") + assertTrue(values.contains(WalletKitEngineKind.QUICKJS)) + } + + @Test + fun `WalletKitEngineKind valueOf works correctly`() { + assertEquals(WalletKitEngineKind.WEBVIEW, WalletKitEngineKind.valueOf("WEBVIEW")) + @Suppress("DEPRECATION") + assertEquals(WalletKitEngineKind.QUICKJS, WalletKitEngineKind.valueOf("QUICKJS")) + } + + @Test + fun `WalletKitEngineKind name property works`() { + assertEquals("WEBVIEW", WalletKitEngineKind.WEBVIEW.name) + @Suppress("DEPRECATION") + assertEquals("QUICKJS", WalletKitEngineKind.QUICKJS.name) + } + + @Test + fun `WalletKitEngineKind ordinal property works`() { + assertEquals(0, WalletKitEngineKind.WEBVIEW.ordinal) + @Suppress("DEPRECATION") + assertEquals(1, WalletKitEngineKind.QUICKJS.ordinal) + } + + @Test + fun `created engine has correct kind property`() { + val engine = WalletKitEngineFactory.create(context, WalletKitEngineKind.WEBVIEW) + val kind: WalletKitEngineKind = engine.kind + assertEquals(WalletKitEngineKind.WEBVIEW, kind) + } + + @Test + fun `created engine implements WalletKitEngine interface`() { + val engine = WalletKitEngineFactory.create(context) + assertTrue(engine is WalletKitEngine) + } + + @Test + fun `createQuickJsEngine reflection handles ClassNotFoundException`() { + @Suppress("DEPRECATION") + val isAvailable = WalletKitEngineFactory.isAvailable(WalletKitEngineKind.QUICKJS) + if (!isAvailable) { + try { + @Suppress("DEPRECATION") + WalletKitEngineFactory.create(context, WalletKitEngineKind.QUICKJS) + fail("Should throw IllegalStateException") + } catch (e: IllegalStateException) { + assertNotNull(e.message) + assertTrue(e.message?.contains("QuickJS engine is not available") == true) + } + } + } + + @Test + fun `create covers all when branches for engine kinds`() { + val webViewEngine = WalletKitEngineFactory.create(context, WalletKitEngineKind.WEBVIEW) + assertEquals(WalletKitEngineKind.WEBVIEW, webViewEngine.kind) + + @Suppress("DEPRECATION") + val quickJsAvailable = WalletKitEngineFactory.isAvailable(WalletKitEngineKind.QUICKJS) + if (quickJsAvailable) { + @Suppress("DEPRECATION") + val quickJsEngine = WalletKitEngineFactory.create(context, WalletKitEngineKind.QUICKJS) + @Suppress("DEPRECATION") + assertEquals(WalletKitEngineKind.QUICKJS, quickJsEngine.kind) + } + } + + @Test + fun `isAvailable covers all when branches`() { + val webViewAvailable = WalletKitEngineFactory.isAvailable(WalletKitEngineKind.WEBVIEW) + assertTrue(webViewAvailable) + + @Suppress("DEPRECATION") + val quickJsAvailable = WalletKitEngineFactory.isAvailable(WalletKitEngineKind.QUICKJS) + assertNotNull(quickJsAvailable) + } + + @Test + fun `engine interface methods with default parameters exist`() { + val engine = WalletKitEngineFactory.create(context) + val interfaceMethods = WalletKitEngine::class.java.methods + + val methodsWithDefaults = listOf( + "init", + "addWalletFromMnemonic", + "getRecentTransactions", + "sendTransaction", + "rejectConnect", + "rejectTransaction", + "rejectSignData", + "disconnectSession", + ) + + for (methodName in methodsWithDefaults) { + val hasMethod = interfaceMethods.any { it.name == methodName } + assertTrue("Engine should have $methodName method", hasMethod) + } + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WalletKitExceptionTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WalletKitExceptionTest.kt new file mode 100644 index 000000000..68fca24d8 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WalletKitExceptionTest.kt @@ -0,0 +1,53 @@ +package io.ton.walletkit.bridge + +import io.ton.walletkit.presentation.WalletKitBridgeException +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for WalletKit exception handling to ensure errors + * are properly typed and informative. + */ +class WalletKitExceptionTest { + + @Test + fun `WalletKitBridgeException extends Exception`() { + val exception = WalletKitBridgeException("Test error") + assertTrue(exception is Exception) + } + + @Test + fun `exception preserves message`() { + val message = "Wallet initialization failed" + val exception = WalletKitBridgeException(message) + assertEquals(message, exception.message) + } + + @Test + fun `exception can be caught as Exception`() { + try { + throw WalletKitBridgeException("Test") + } catch (e: Exception) { + assertNotNull(e) + assertTrue(e is WalletKitBridgeException) + } + } + + @Test + fun `exception can be caught as WalletKitBridgeException`() { + try { + throw WalletKitBridgeException("Specific error") + } catch (e: WalletKitBridgeException) { + assertEquals("Specific error", e.message) + } + } + + @Test + fun `exception supports stack traces`() { + val exception = WalletKitBridgeException("Test error") + assertNotNull(exception.stackTrace) + assertTrue(exception.stackTrace.isNotEmpty()) + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineApiTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineApiTest.kt new file mode 100644 index 000000000..7f0176fb2 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineApiTest.kt @@ -0,0 +1,164 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import io.ton.walletkit.presentation.impl.WebViewWalletKitEngine +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for WebViewWalletKitEngine API surface. + * Verifies that all public methods from WalletKitEngine interface are implemented. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class WebViewEngineApiTest { + private lateinit var context: Context + private lateinit var engine: WebViewWalletKitEngine + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + engine = WebViewWalletKitEngine(context) + flushMainThread() + } + + @Test + fun `engine implements init method`() { + assertNotNull(engine::init) + } + + @Test + fun `engine implements addWalletFromMnemonic method`() { + assertNotNull(engine::addWalletFromMnemonic) + } + + @Test + fun `engine implements getWallets method`() { + assertNotNull(engine::getWallets) + } + + @Test + fun `engine implements removeWallet method`() { + assertNotNull(engine::removeWallet) + } + + @Test + fun `engine implements getWalletState method`() { + assertNotNull(engine::getWalletState) + } + + @Test + fun `engine implements getRecentTransactions method`() { + assertNotNull(engine::getRecentTransactions) + } + + @Test + fun `engine implements handleTonConnectUrl method`() { + assertNotNull(engine::handleTonConnectUrl) + } + + @Test + fun `engine implements sendTransaction method`() { + assertNotNull(engine::sendTransaction) + } + + @Test + fun `engine implements approveConnect method`() { + assertNotNull(engine::approveConnect) + } + + @Test + fun `engine implements rejectConnect method`() { + assertNotNull(engine::rejectConnect) + } + + @Test + fun `engine implements approveTransaction method`() { + assertNotNull(engine::approveTransaction) + } + + @Test + fun `engine implements rejectTransaction method`() { + assertNotNull(engine::rejectTransaction) + } + + @Test + fun `engine implements approveSignData method`() { + assertNotNull(engine::approveSignData) + } + + @Test + fun `engine implements rejectSignData method`() { + assertNotNull(engine::rejectSignData) + } + + @Test + fun `engine implements listSessions method`() { + assertNotNull(engine::listSessions) + } + + @Test + fun `engine implements disconnectSession method`() { + assertNotNull(engine::disconnectSession) + } + + @Test + fun `engine implements destroy method`() { + assertNotNull(engine::destroy) + } + + @Test + fun `engine implements addEventHandler method`() { + assertNotNull(engine::addEventHandler) + } + + @Test + fun `all core wallet operations are present`() { + val methods = listOf( + engine::init, + engine::addWalletFromMnemonic, + engine::getWallets, + engine::removeWallet, + engine::getWalletState, + engine::getRecentTransactions, + ) + + assertTrue(methods.all { it != null }) + } + + @Test + fun `all TonConnect operations are present`() { + val methods = listOf( + engine::handleTonConnectUrl, + engine::approveConnect, + engine::rejectConnect, + engine::approveTransaction, + engine::rejectTransaction, + engine::approveSignData, + engine::rejectSignData, + engine::listSessions, + engine::disconnectSession, + ) + + assertTrue(methods.all { it != null }) + } + + @Test + fun `engine has WebView-specific methods`() { + // These are WebView-specific, not in base interface + assertNotNull(engine::asView) + assertNotNull(engine::attachTo) + } + + private fun flushMainThread() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineAutoInitTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineAutoInitTest.kt new file mode 100644 index 000000000..b1ad30eb6 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineAutoInitTest.kt @@ -0,0 +1,119 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.impl.WebViewWalletKitEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import kotlin.test.assertNotNull + +/** + * Tests for WebViewWalletKitEngine auto-initialization feature. + * Verifies that the engine can automatically initialize itself on first use. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class WebViewEngineAutoInitTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var context: Context + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `engine has auto-init capability`() = runTest { + val engine = WebViewWalletKitEngine(context) + assertNotNull(engine) + + // Auto-init happens on first call to any method + // We can't easily test it without a full WebView environment, + // but we verify the engine supports the init() method + assertNotNull(engine::init) + + flushMainThread() + } + + @Test + fun `init accepts custom config`() = runTest { + val engine = WebViewWalletKitEngine(context) + + val config = WalletKitBridgeConfig( + network = "mainnet", + enablePersistentStorage = false, + ) + + // Should not throw - init() signature is correct + // Actual initialization would fail without JS bundle, which is expected + assertNotNull(config) + + flushMainThread() + } + + @Test + fun `init accepts testnet config`() = runTest { + val engine = WebViewWalletKitEngine(context) + + val config = WalletKitBridgeConfig( + network = "testnet", + tonApiUrl = "https://testnet.tonapi.io", + ) + + assertNotNull(config) + assertNotNull(engine) + + flushMainThread() + } + + @Test + fun `engine supports storage configuration`() = runTest { + val engine = WebViewWalletKitEngine(context) + + val withStorage = WalletKitBridgeConfig(enablePersistentStorage = true) + val withoutStorage = WalletKitBridgeConfig(enablePersistentStorage = false) + + assertNotNull(withStorage) + assertNotNull(withoutStorage) + assertNotNull(engine) + + flushMainThread() + } + + @Test + fun `engine can be created multiple times`() = runTest { + val engine1 = WebViewWalletKitEngine(context) + val engine2 = WebViewWalletKitEngine(context) + val engine3 = WebViewWalletKitEngine(context) + + assertNotNull(engine1) + assertNotNull(engine2) + assertNotNull(engine3) + + flushMainThread() + } + + private fun flushMainThread() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineDeepTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineDeepTest.kt new file mode 100644 index 000000000..4194418af --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineDeepTest.kt @@ -0,0 +1,391 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import io.ton.walletkit.domain.model.Transaction +import io.ton.walletkit.domain.model.TransactionType +import io.ton.walletkit.domain.model.WalletAccount +import io.ton.walletkit.presentation.WalletKitBridgeException +import io.ton.walletkit.presentation.config.WalletKitBridgeConfig +import io.ton.walletkit.presentation.event.WalletKitEvent +import io.ton.walletkit.presentation.impl.WebViewWalletKitEngine +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.lang.reflect.Method +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Deep integration tests for WebViewWalletKitEngine that actually execute code paths. + * Uses reflection and mocking to test internal bridge communication. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class WebViewEngineDeepTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var context: Context + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `handleResponse processes successful result`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + // Create a mock response + val response = JSONObject().apply { + put( + "result", + JSONObject().apply { + put("balance", "1000000000") + put("status", "active") + }, + ) + } + + // Use reflection to call handleResponse + val callId = "test-call-123" + val deferred = CompletableDeferred() + + // Set up pending call + val pendingField = getPrivateField(engine, "pending") + + @Suppress("UNCHECKED_CAST") + val pending = pendingField.get(engine) as java.util.concurrent.ConcurrentHashMap> + pending[callId] = deferred + + // Call handleResponse + val handleResponseMethod = getPrivateMethod(engine, "handleResponse", String::class.java, JSONObject::class.java) + handleResponseMethod.invoke(engine, callId, response) + + // Verify response was processed + assertTrue(deferred.isCompleted) + } + + @Test + fun `handleResponse processes error response`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + val response = JSONObject().apply { + put( + "error", + JSONObject().apply { + put("message", "Test error message") + }, + ) + } + + val callId = "test-call-456" + val deferred = CompletableDeferred() + + val pendingField = getPrivateField(engine, "pending") + + @Suppress("UNCHECKED_CAST") + val pending = pendingField.get(engine) as java.util.concurrent.ConcurrentHashMap> + pending[callId] = deferred + + val handleResponseMethod = getPrivateMethod(engine, "handleResponse", String::class.java, JSONObject::class.java) + handleResponseMethod.invoke(engine, callId, response) + + assertTrue(deferred.isCompleted) + // Verify it completed exceptionally + val exception = assertFailsWith { + runTest { + deferred.await() + } + } + assertTrue(exception is WalletKitBridgeException || exception.cause is WalletKitBridgeException) + } + + @Test + fun `handleEvent dispatches to event handlers`() = runTest { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + var eventReceived: WalletKitEvent? = null + val handler = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + eventReceived = event + } + } + + engine.addEventHandler(handler) + + // Create a sessions changed event (simplest one) + val event = JSONObject().apply { + put("type", "sessionsChanged") + put("data", JSONObject()) + } + + val handleEventMethod = getPrivateMethod(engine, "handleEvent", JSONObject::class.java) + handleEventMethod.invoke(engine, event) + + flushMainThread() // Let the event dispatch on main thread + + // Verify event was dispatched + assertNotNull(eventReceived) + assertTrue(eventReceived is WalletKitEvent.SessionsChangedEvent) + } + + @Test + fun `config validation stores network setting`() = runTest { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + val config = WalletKitBridgeConfig( + network = "mainnet", + tonApiUrl = "https://tonapi.io", + ) + + // Access private field to verify config was stored + val networkField = getPrivateField(engine, "currentNetwork") + val initialNetwork = networkField.get(engine) as String + assertEquals("testnet", initialNetwork) // Default before init + + // The actual init would set it, but we can verify the config object exists + assertNotNull(config) + assertEquals("mainnet", config.network) + } + + @Test + fun `parseTransactions converts JSON to Transaction objects`() { + val txJson = JSONObject().apply { + put("hash", "abc123") + put("timestamp", 1697000000L) + put("amount", "500000000") + put("fee", "1000000") + put("type", "incoming") + put("sender", "EQDsender...") + put("recipient", "EQDrecipient...") + } + + val txArray = JSONArray().apply { + put(txJson) + } + + // Test Transaction model directly + val tx = Transaction( + hash = txJson.getString("hash"), + timestamp = txJson.getLong("timestamp"), + amount = txJson.getString("amount"), + fee = txJson.optString("fee"), + type = TransactionType.INCOMING, + sender = txJson.optString("sender"), + recipient = txJson.optString("recipient"), + ) + + assertEquals("abc123", tx.hash) + assertEquals(1697000000L, tx.timestamp) + assertEquals("500000000", tx.amount) + assertEquals(TransactionType.INCOMING, tx.type) + } + + @Test + fun `parseWalletAccount converts JSON to WalletAccount object`() { + val walletJson = JSONObject().apply { + put("address", "EQDtest123...") + put("publicKey", "pubkey-hex") + put("name", "Test Wallet") + put("version", "v5r1") + put("network", "mainnet") + put("index", 0) + } + + val wallet = WalletAccount( + address = walletJson.getString("address"), + publicKey = walletJson.optString("publicKey"), + name = walletJson.optString("name"), + version = walletJson.getString("version"), + network = walletJson.getString("network"), + index = walletJson.getInt("index"), + ) + + assertEquals("EQDtest123...", wallet.address) + assertEquals("pubkey-hex", wallet.publicKey) + assertEquals("Test Wallet", wallet.name) + assertEquals("v5r1", wallet.version) + assertEquals("mainnet", wallet.network) + assertEquals(0, wallet.index) + } + + @Test + fun `event handler registration and removal works`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + val handler1 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) {} + } + val handler2 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) {} + } + + val closeable1 = engine.addEventHandler(handler1) + val closeable2 = engine.addEventHandler(handler2) + + // Verify handlers are registered + val handlersField = getPrivateField(engine, "eventHandlers") + + @Suppress("UNCHECKED_CAST") + val handlers = handlersField.get(engine) as java.util.concurrent.CopyOnWriteArraySet<*> + assertEquals(2, handlers.size) + + // Remove handler1 + closeable1.close() + assertEquals(1, handlers.size) + + // Remove handler2 + closeable2.close() + assertEquals(0, handlers.size) + } + + @Test + fun `multiple event handlers all receive events`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + var handler1Called = false + var handler2Called = false + var handler3Called = false + + val handler1 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + handler1Called = true + } + } + val handler2 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + handler2Called = true + } + } + val handler3 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + handler3Called = true + } + } + + engine.addEventHandler(handler1) + engine.addEventHandler(handler2) + engine.addEventHandler(handler3) + + // Trigger event (sessionsChanged is simplest) + val event = JSONObject().apply { + put("type", "sessionsChanged") + put("data", JSONObject()) + } + + val handleEventMethod = getPrivateMethod(engine, "handleEvent", JSONObject::class.java) + handleEventMethod.invoke(engine, event) + + flushMainThread() // Let events dispatch on main thread + + assertTrue(handler1Called) + assertTrue(handler2Called) + assertTrue(handler3Called) + } + + @Test + fun `WebView settings are configured correctly`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + val webView = engine.asView() + + // Verify all critical settings + assertTrue(webView.settings.javaScriptEnabled, "JS must be enabled") + assertTrue(webView.settings.domStorageEnabled, "DOM storage must be enabled") + assertTrue(webView.settings.allowFileAccess, "File access must be enabled for assets") + } + + @Test + fun `storage adapter methods are called for persistence`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + // Access storage adapter + val storageField = getPrivateField(engine, "storageAdapter") + val storageAdapter = storageField.get(engine) + assertNotNull(storageAdapter) + + // Verify storage is properly configured + val persistentField = getPrivateField(engine, "persistentStorageEnabled") + val isPersistent = persistentField.get(engine) as Boolean + assertTrue(isPersistent) // Default is true + } + + @Test + fun `config with disabled storage sets flag correctly`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() + + val config = WalletKitBridgeConfig(enablePersistentStorage = false) + + // Verify config structure + assertTrue(!config.enablePersistentStorage) + + // The flag would be set during init - verify the config itself is correct + assertEquals(false, config.enablePersistentStorage) + } + + private fun flushMainThread() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + } + + private fun getPrivateField(obj: Any, fieldName: String): Field { + var clazz: Class<*>? = obj.javaClass + while (clazz != null) { + try { + val field = clazz.getDeclaredField(fieldName) + field.isAccessible = true + return field + } catch (e: NoSuchFieldException) { + clazz = clazz.superclass + } + } + throw NoSuchFieldException("Field $fieldName not found") + } + + private fun getPrivateMethod(obj: Any, methodName: String, vararg parameterTypes: Class<*>): Method { + var clazz: Class<*>? = obj.javaClass + while (clazz != null) { + try { + val method = clazz.getDeclaredMethod(methodName, *parameterTypes) + method.isAccessible = true + return method + } catch (e: NoSuchMethodException) { + clazz = clazz.superclass + } + } + throw NoSuchMethodException("Method $methodName not found") + } +} diff --git a/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineTest.kt b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineTest.kt new file mode 100644 index 000000000..291942893 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/bridge/src/test/java/io/ton/walletkit/bridge/WebViewEngineTest.kt @@ -0,0 +1,186 @@ +package io.ton.walletkit.bridge + +import android.content.Context +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import io.ton.walletkit.presentation.WalletKitEngineKind +import io.ton.walletkit.presentation.event.WalletKitEvent +import io.ton.walletkit.presentation.impl.WebViewWalletKitEngine +import io.ton.walletkit.presentation.listener.WalletKitEventHandler +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for WebViewWalletKitEngine initialization and basic functionality. + * These tests verify the engine can be created, configured, and supports event handling. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class WebViewEngineTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `engine can be instantiated`() { + val engine = WebViewWalletKitEngine(context) + assertNotNull(engine) + flushMainThread() + } + + @Test + fun `engine has correct kind`() { + val engine = WebViewWalletKitEngine(context) + assertEquals(WalletKitEngineKind.WEBVIEW, engine.kind) + flushMainThread() + } + + @Test + fun `engine supports custom asset path`() { + val customPath = "custom/path/index.html" + val engine = WebViewWalletKitEngine(context, assetPath = customPath) + assertNotNull(engine) + flushMainThread() + } + + @Test + fun `engine exposes WebView for debugging`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() // Let WebView initialize on main thread + val webView = engine.asView() + assertNotNull(webView) + assertTrue(webView.settings.javaScriptEnabled) + } + + @Test + fun `event handler can be registered`() { + val engine = WebViewWalletKitEngine(context) + var eventCount = 0 + + val handler = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) { + eventCount++ + } + } + + val closeable = engine.addEventHandler(handler) + assertNotNull(closeable) + + flushMainThread() + + // Clean up + closeable.close() + } + + @Test + fun `multiple event handlers can be registered`() { + val engine = WebViewWalletKitEngine(context) + + val handler1 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) {} + } + val handler2 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) {} + } + val handler3 = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) {} + } + + val c1 = engine.addEventHandler(handler1) + val c2 = engine.addEventHandler(handler2) + val c3 = engine.addEventHandler(handler3) + + assertNotNull(c1) + assertNotNull(c2) + assertNotNull(c3) + + flushMainThread() + + // Clean up + c1.close() + c2.close() + c3.close() + } + + @Test + fun `event handler can be unregistered`() { + val engine = WebViewWalletKitEngine(context) + + val handler = object : WalletKitEventHandler { + override fun handleEvent(event: WalletKitEvent) {} + } + + val closeable = engine.addEventHandler(handler) + closeable.close() // Should not throw + + flushMainThread() + } + + @Test + fun `engine can be destroyed`() { + val engine = WebViewWalletKitEngine(context) + + flushMainThread() + + // Should not throw + // Note: destroy() is suspend, so we can't easily test it here + // This is more of a smoke test + assertNotNull(engine) + } + + @Test + fun `WebView has correct settings`() { + val engine = WebViewWalletKitEngine(context) + flushMainThread() // Let WebView initialize on main thread + val webView = engine.asView() + + assertTrue(webView.settings.javaScriptEnabled, "JavaScript should be enabled") + assertTrue(webView.settings.domStorageEnabled, "DOM storage should be enabled") + } + + @Test + fun `WebView has JavaScript interface`() { + val engine = WebViewWalletKitEngine(context) + val webView = engine.asView() + + // The interface "WalletKitNative" should be injected + // We can't easily test this without executing JS, but the engine should not crash + assertNotNull(webView) + + flushMainThread() + } + + @Test + fun `engine supports default asset path`() { + val engine = WebViewWalletKitEngine(context) + // Default path is "walletkit/index.html" + assertNotNull(engine) + flushMainThread() + } + + @Test + fun `engine uses application context`() { + val activityContext = ApplicationProvider.getApplicationContext() + val engine = WebViewWalletKitEngine(activityContext) + + // Engine should work with application context + assertNotNull(engine) + assertEquals(WalletKitEngineKind.WEBVIEW, engine.kind) + + flushMainThread() + } + + private fun flushMainThread() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/apps/androidkit/TONWalletKit-Android/build.gradle.kts b/apps/androidkit/TONWalletKit-Android/build.gradle.kts new file mode 100644 index 000000000..997fe0f69 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/build.gradle.kts @@ -0,0 +1,110 @@ +// Top-level build file for TONWalletKit-Android SDK +plugins { + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinAndroid) apply false + alias(libs.plugins.spotless) apply false +} + +// Apply Spotless formatting to all subprojects +subprojects { + apply(plugin = "com.diffplug.spotless") + + configure { + kotlin { + target("**/*.kt") + targetExclude( + "**/build/**/*.kt", + "**/WalletKitEngineFactoryTest.kt", + ) + ktlint("1.0.1") + .editorConfigOverride( + mapOf( + "ktlint_standard_no-wildcard-imports" to "disabled", + "ktlint_standard_filename" to "disabled", + ) + ) + } + kotlinGradle { + target("**/*.gradle.kts") + ktlint("1.0.1") + } + } +} + +// Task to build WebView variant only (lightweight, recommended) +tasks.register("buildWebview") { + group = "build" + description = "Build WebView-only SDK variant (lightweight, no QuickJS)" + + dependsOn(":bridge:assembleWebviewRelease") + + doLast { + println("✅ WebView variant built: bridge-webview-release.aar (1.2M)") + println(" No QuickJS native libs, no OkHttp dependency") + } +} + +// Task to build Full variant with QuickJS +tasks.register("buildFull") { + group = "build" + description = "Build Full SDK variant (includes QuickJS)" + + dependsOn(":bridge:assembleFullRelease") + + doLast { + println("✅ Full variant built: bridge-full-release.aar (4.3M)") + println(" Includes QuickJS native libs + OkHttp") + } +} + +// Task to build both variants +tasks.register("buildAllVariants") { + group = "build" + description = "Build all SDK variants (webview, full)" + + dependsOn("buildWebview", "buildFull") +} + +// Task to build and copy WebView variant to demo (default, recommended) +tasks.register("buildAndCopyWebviewToDemo") { + group = "build" + description = "Build WebView SDK variant and copy to AndroidDemo/app/libs (default)" + + dependsOn(":bridge:assembleWebviewRelease") + + from(layout.projectDirectory.file("bridge/build/outputs/aar/bridge-webview-release.aar")) + into(layout.projectDirectory.dir("../AndroidDemo/app/libs")) + rename("bridge-webview-release.aar", "bridge-release.aar") + + doLast { + println("✅ WebView variant copied to AndroidDemo/app/libs/bridge-release.aar") + println(" Size: ~1.2M (no QuickJS)") + println(" Demo app uses: WebView engine only") + } +} + +// Task to build and copy Full variant to demo +tasks.register("buildAndCopyFullToDemo") { + group = "build" + description = "Build Full SDK variant (with QuickJS) and copy to AndroidDemo/app/libs" + + dependsOn(":bridge:assembleFullRelease") + + from(layout.projectDirectory.file("bridge/build/outputs/aar/bridge-full-release.aar")) + into(layout.projectDirectory.dir("../AndroidDemo/app/libs")) + rename("bridge-full-release.aar", "bridge-release.aar") + + doLast { + println("✅ Full variant copied to AndroidDemo/app/libs/bridge-release.aar") + println(" Size: ~4.3M (includes QuickJS)") + println(" Demo app uses: WebView + QuickJS engines") + } +} + +// Alias for backward compatibility (uses webview by default) +tasks.register("buildAndCopyToDemo") { + group = "build" + description = "Build and copy WebView variant to demo (alias for buildAndCopyWebviewToDemo)" + + dependsOn("buildAndCopyWebviewToDemo") +} diff --git a/apps/androidkit/TONWalletKit-Android/gradle.properties b/apps/androidkit/TONWalletKit-Android/gradle.properties new file mode 100644 index 000000000..1f1277f34 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx2g -Dkotlin.daemon.jvm.options=-Xmx2g diff --git a/apps/androidkit/TONWalletKit-Android/gradle/libs.versions.toml b/apps/androidkit/TONWalletKit-Android/gradle/libs.versions.toml new file mode 100644 index 000000000..6a5774f83 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradle/libs.versions.toml @@ -0,0 +1,43 @@ +[versions] +agp = "8.13.0" +kotlin = "2.2.20" +spotless = "8.0.0" +androidxCoreKtx = "1.17.0" +lifecycle = "2.9.4" +coroutinesAndroid = "1.10.2" +kotlinxSerialization = "1.9.0" +webkit = "1.14.0" +datastorePreferences = "1.1.7" +securityCrypto = "1.1.0" +okhttp = "5.2.1" +junit = "4.13.2" +androidxTestExt = "1.3.0" +androidxTestRunner = "1.7.0" +mockk = "1.14.6" +androidxTestCore = "1.7.0" +robolectric = "4.16" +coroutinesTest = "1.10.2" + +[libraries] +androidxCoreKtx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } +androidxLifecycleRuntimeKtx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +kotlinxCoroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesAndroid" } +kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +androidxWebkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +androidxDatastorePreferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidxSecurityCrypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +junit = { module = "junit:junit", version.ref = "junit" } +androidxTestExt = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" } +androidxTestRunner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +androidxTestCore = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +shadowsFramework = { module = "org.robolectric:shadows-framework", version.ref = "robolectric" } +kotlinxCoroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } + +[plugins] +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/META-INF/MANIFEST.MF b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/META-INF/MANIFEST.MF new file mode 100644 index 000000000..9db312838 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/META-INF/MANIFEST.MF @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +Implementation-Title: Gradle +Implementation-Version: 8.7 + diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-base-annotations.jar b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-base-annotations.jar new file mode 100644 index 000000000..fcb8eb4b0 Binary files /dev/null and b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-base-annotations.jar differ diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-cli.jar b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-cli.jar new file mode 100644 index 000000000..527929daa Binary files /dev/null and b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-cli.jar differ diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-files.jar b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-files.jar new file mode 100644 index 000000000..0ab4bd80d Binary files /dev/null and b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-files.jar differ diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-functional.jar b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-functional.jar new file mode 100644 index 000000000..921ca53c9 Binary files /dev/null and b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-functional.jar differ diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper-classpath.properties b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper-classpath.properties new file mode 100644 index 000000000..68be06016 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper-classpath.properties @@ -0,0 +1,2 @@ +projects=gradle-base-annotations,gradle-cli,gradle-files,gradle-functional,gradle-wrapper-shared +runtime= diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper-shared.jar b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper-shared.jar new file mode 100644 index 000000000..651d485b9 Binary files /dev/null and b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper-shared.jar differ diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper.jar b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..c8ea8b24b Binary files /dev/null and b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper.properties b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..d706aba60 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/androidkit/TONWalletKit-Android/gradlew b/apps/androidkit/TONWalletKit-Android/gradlew new file mode 100755 index 000000000..e8a7f0f30 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradlew @@ -0,0 +1,18 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +APP_BASE_NAME=`basename "$0"` +APP_HOME=`dirname "$0"` + +DEFAULT_JVM_OPTS="-Xmx64m -Xms64m" + +GRADLE_WRAPPER_JAR="$APP_HOME/gradle/wrapper/gradle-wrapper.jar" +GRADLE_WRAPPER_SHARED_JAR="$APP_HOME/gradle/wrapper/gradle-wrapper-shared.jar" +CLASSPATH="$GRADLE_WRAPPER_JAR:$GRADLE_WRAPPER_SHARED_JAR:$APP_HOME/gradle/wrapper/gradle-cli.jar:$APP_HOME/gradle/wrapper/gradle-files.jar:$APP_HOME/gradle/wrapper/gradle-functional.jar:$APP_HOME/gradle/wrapper/gradle-base-annotations.jar" + +exec java $DEFAULT_JVM_OPTS -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/apps/androidkit/TONWalletKit-Android/gradlew.bat b/apps/androidkit/TONWalletKit-Android/gradlew.bat new file mode 100644 index 000000000..fa00e4774 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/gradlew.bat @@ -0,0 +1,5 @@ +@ECHO OFF +set APP_HOME=%~dp0 +set DEFAULT_JVM_OPTS=-Xmx64m -Xms64m +set GRADLE_WRAPPER_JAR=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +java -jar "%GRADLE_WRAPPER_JAR%" %* diff --git a/apps/androidkit/TONWalletKit-Android/settings.gradle.kts b/apps/androidkit/TONWalletKit-Android/settings.gradle.kts new file mode 100644 index 000000000..2bd1ccc55 --- /dev/null +++ b/apps/androidkit/TONWalletKit-Android/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + @Suppress("UnstableApiUsage") + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "TONWalletKit-Android" + +include(":bridge") diff --git a/apps/androidkit/index.html b/apps/androidkit/index.html new file mode 100644 index 000000000..373aa2a7d --- /dev/null +++ b/apps/androidkit/index.html @@ -0,0 +1,16 @@ + + + + + + WalletKit Android Adapter + + + +
Loading WalletKit...
+ + + \ No newline at end of file diff --git a/apps/androidkit/js/Makefile b/apps/androidkit/js/Makefile new file mode 100644 index 000000000..e601bac5c --- /dev/null +++ b/apps/androidkit/js/Makefile @@ -0,0 +1,5 @@ +build-js: + pnpm build + +build-all: + pnpm run build:all diff --git a/apps/androidkit/js/src/bridge.ts b/apps/androidkit/js/src/bridge.ts new file mode 100644 index 000000000..d14539a56 --- /dev/null +++ b/apps/androidkit/js/src/bridge.ts @@ -0,0 +1,1043 @@ +import type { WalletKitBridgeEvent, WalletKitBridgeInitConfig } from './types'; + +const walletKitModulePromise = import('@ton/walletkit'); +const tonCoreModulePromise = import('@ton/core'); + +let TonWalletKit: any; +let createWalletInitConfigMnemonic: any; +let createWalletManifest: any; +let Address: any; +let Cell: any; +let currentNetwork: 'mainnet' | 'testnet' = 'testnet'; +let currentApiBase = 'https://testnet.tonapi.io'; +type TonChainEnum = { MAINNET: number; TESTNET: number }; +let tonConnectChain: TonChainEnum | null = null; + +async function ensureWalletKitLoaded() { + if (TonWalletKit && createWalletInitConfigMnemonic && tonConnectChain && Address && Cell) { + return; + } + if (!TonWalletKit || !createWalletInitConfigMnemonic) { + const module = await walletKitModulePromise; + TonWalletKit = (module as any).TonWalletKit; + createWalletInitConfigMnemonic = (module as any).createWalletInitConfigMnemonic; + createWalletManifest = (module as any).createWalletManifest ?? createWalletManifest; + tonConnectChain = (module as any).CHAIN ?? tonConnectChain; + } + // Load Address and Cell from @ton/core + if (!Address || !Cell) { + const coreModule = await tonCoreModulePromise; + Address = (coreModule as any).Address; + Cell = (coreModule as any).Cell; + } + if (!tonConnectChain) { + const module = await walletKitModulePromise; + tonConnectChain = (module as any).CHAIN ?? null; + if (!tonConnectChain) { + throw new Error('TonWalletKit did not expose CHAIN enum'); + } + } +} + +// Helper to convert raw address (0:hex) to user-friendly format (UQ...) +function toUserFriendlyAddress(rawAddress: string | null): string | null { + if (!rawAddress || !Address) return rawAddress; + try { + const addr = Address.parse(rawAddress); + return addr.toString({ bounceable: false, testOnly: currentNetwork === 'testnet' }); + } catch (e) { + console.warn('[walletkitBridge] Failed to parse address:', rawAddress, e); + return rawAddress; + } +} + +// Helper to convert base64 hash to hex +function base64ToHex(base64: string): string { + try { + // Decode base64 to binary string + const binaryString = atob(base64); + let hex = ''; + for (let i = 0; i < binaryString.length; i++) { + const hexByte = binaryString.charCodeAt(i).toString(16).padStart(2, '0'); + hex += hexByte; + } + return hex; + } catch (e) { + console.warn('[walletkitBridge] Failed to convert hash to hex:', base64, e); + return base64; + } +} + +// Helper to extract text comment from message body +function extractTextComment(messageBody: string | null): string | null { + if (!messageBody || !Cell) return null; + try { + const cell = Cell.fromBase64(messageBody); + const slice = cell.beginParse(); + + // Check if it starts with 0x00000000 (text comment opcode) + const opcode = slice.loadUint(32); + if (opcode === 0) { + // Read the rest as a string + return slice.loadStringTail(); + } + return null; + } catch (e) { + // Not a text comment or failed to parse + return null; + } +} + +type WalletKitApiMethod = keyof typeof api; + +type BridgePayload = + | { kind: 'response'; id: string; result?: unknown; error?: { message: string } } + | { kind: 'event'; event: WalletKitBridgeEvent } + | { + kind: 'ready'; + network?: string; + tonApiUrl?: string; + tonClientEndpoint?: string; + source?: string; + timestamp?: number; + } + | { + kind: 'diagnostic-call'; + id: string; + method: WalletKitApiMethod; + stage: 'start' | 'checkpoint' | 'success' | 'error'; + timestamp: number; + message?: string; + }; + +declare global { + interface Window { + walletkitBridge?: typeof api; + __walletkitCall?: (id: string, method: WalletKitApiMethod, paramsJson?: string | null) => void; + WalletKitNative?: { postMessage: (json: string) => void }; + AndroidBridge?: { postMessage: (json: string) => void }; + } +} + +type CallContext = { + id: string; + method: WalletKitApiMethod; +}; + +let walletKit: any | null = null; +let initialized = false; + +function resolveTonConnectUrl(input: unknown): string | null { + console.log('[walletkitBridge] resolveTonConnectUrl called with input type:', typeof input); + if (input == null) { + console.log('[walletkitBridge] input is null/undefined'); + return null; + } + + if (typeof input === 'string') { + const trimmed = input.trim(); + console.log('[walletkitBridge] input is string, trimmed:', trimmed.substring(0, 100)); + if (!trimmed) { + return null; + } + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) as unknown; + return resolveTonConnectUrl(parsed); + } catch (_error) { + return null; + } + } + return trimmed; + } + + if (Array.isArray(input)) { + console.log('[walletkitBridge] input is array, length:', input.length); + for (const item of input) { + const resolved = resolveTonConnectUrl(item); + if (resolved) { + return resolved; + } + } + return null; + } + + if (typeof input === 'object') { + console.log('[walletkitBridge] input is object, keys:', Object.keys(input)); + const record = input as Record; + const candidates = [ + record.url, + record.href, + record.link, + record.location, + record.requestUrl, + record.tonconnectUrl, + record.value, + ]; + for (const candidate of candidates) { + if (typeof candidate === 'string') { + const trimmed = candidate.trim(); + if (trimmed) { + console.log('[walletkitBridge] found candidate URL:', trimmed.substring(0, 100)); + return trimmed; + } + } + } + + const nestedSources = [record.params, record.payload, record.data, record.body]; + for (const source of nestedSources) { + const resolved = resolveTonConnectUrl(source); + if (resolved) { + return resolved; + } + } + } + + console.log('[walletkitBridge] no URL found in input'); + return null; +} + +function resolveGlobalScope(): typeof globalThis { + if (typeof globalThis !== 'undefined') { + return globalThis; + } + if (typeof window !== 'undefined') { + return window as typeof globalThis; + } + if (typeof self !== 'undefined') { + return self as typeof globalThis; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +function resolveNativeBridge(scope: typeof globalThis) { + const candidate = (scope as typeof globalThis & { WalletKitNative?: { postMessage?: (json: string) => void } }).WalletKitNative; + if (candidate && typeof candidate.postMessage === 'function') { + return candidate.postMessage.bind(candidate); + } + const windowRef = typeof scope.window === 'object' && scope.window ? scope.window : undefined; + const windowCandidate = windowRef?.WalletKitNative; + if (windowCandidate && typeof windowCandidate.postMessage === 'function') { + return windowCandidate.postMessage.bind(windowCandidate); + } + return null; +} + +function resolveAndroidBridge(scope: typeof globalThis) { + const candidate = (scope as typeof globalThis & { AndroidBridge?: { postMessage?: (json: string) => void } }).AndroidBridge; + if (candidate && typeof candidate.postMessage === 'function') { + return candidate.postMessage.bind(candidate); + } + const windowRef = typeof scope.window === 'object' && scope.window ? scope.window : undefined; + const windowCandidate = windowRef?.AndroidBridge; + if (windowCandidate && typeof windowCandidate.postMessage === 'function') { + return windowCandidate.postMessage.bind(windowCandidate); + } + return null; +} + +function postToNative(payload: BridgePayload) { + if (payload === null || (typeof payload !== 'object' && typeof payload !== 'function')) { + const diagnostic = { + type: typeof payload, + value: payload, + stack: new Error('postToNative non-object payload').stack, + }; + console.error('[walletkitBridge] postToNative received non-object payload', diagnostic); + throw new Error('Invalid payload - must be an object'); + } + const json = JSON.stringify(payload); + const scope = resolveGlobalScope(); + const nativePostMessage = resolveNativeBridge(scope); + if (nativePostMessage) { + nativePostMessage(json); + return; + } + const androidPostMessage = resolveAndroidBridge(scope); + if (androidPostMessage) { + androidPostMessage(json); + return; + } + // If neither bridge is available, throw error for events (not for diagnostics/ready) + if (payload.kind === 'event') { + throw new Error('Native bridge not available - cannot deliver event'); + } + // For non-critical messages (diagnostics, ready), just log + console.debug('[walletkitBridge] → native (no handler)', payload); +} + +function emitCallDiagnostic(id: string, method: WalletKitApiMethod, stage: 'start' | 'checkpoint' | 'success' | 'error', message?: string) { + postToNative({ + kind: 'diagnostic-call', + id, + method, + stage, + timestamp: Date.now(), + message, + }); +} + +function emitCallCheckpoint(context: CallContext | undefined, message: string) { + if (!context) return; + emitCallDiagnostic(context.id, context.method, 'checkpoint', message); +} + +function emit(type: WalletKitBridgeEvent['type'], data?: WalletKitBridgeEvent['data']) { + const event: WalletKitBridgeEvent = { type, data }; + + // Send to native immediately - native side is responsible for storing events + postToNative({ kind: 'event', event }); +} + +function respond(id: string, result?: unknown, error?: { message: string }) { + postToNative({ kind: 'response', id, result, error }); +} + +async function handleCall(id: string, method: WalletKitApiMethod, params?: unknown) { + emitCallDiagnostic(id, method, 'start'); + try { + console.log(`[walletkitBridge] handleCall ${method}, looking up api[${method}]`); + const fn = api[method]; + console.log(`[walletkitBridge] fn found:`, typeof fn); + if (typeof fn !== 'function') throw new Error(`Unknown method ${String(method)}`); + const context: CallContext = { id, method }; + console.log(`[walletkitBridge] about to call fn for ${method}`); + const value = await (fn as (args: unknown, context?: CallContext) => Promise | unknown)(params as never, context); + console.log(`[walletkitBridge] fn returned for ${method}`); + emitCallDiagnostic(id, method, 'success'); + respond(id, value); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[walletkitBridge] handleCall error for ${method}:`, err); + console.error(`[walletkitBridge] error type:`, typeof err); + console.error(`[walletkitBridge] error message:`, message); + console.error(`[walletkitBridge] error stack:`, err instanceof Error ? err.stack : 'no stack'); + emitCallDiagnostic(id, method, 'error', message); + respond(id, undefined, { message }); + } +} + +window.__walletkitCall = (id, method, paramsJson) => { + let params: unknown = undefined; + if (paramsJson && paramsJson !== 'null') { + try { + params = JSON.parse(paramsJson); + } catch (err) { + respond(id, undefined, { message: 'Invalid params JSON' }); + return; + } + } + void handleCall(id, method, params); +}; + +async function initTonWalletKit(config?: WalletKitBridgeInitConfig, context?: CallContext) { + if (initialized && walletKit) { + emitCallCheckpoint(context, 'initTonWalletKit:already-initialized'); + return { ok: true }; + } + emitCallCheckpoint(context, 'initTonWalletKit:begin'); + const network = config?.network || 'testnet'; + const tonApiUrl = config?.tonApiUrl || config?.apiBaseUrl || (network === 'mainnet' ? 'https://tonapi.io' : 'https://testnet.tonapi.io'); + const clientEndpoint = config?.tonClientEndpoint || config?.apiUrl || (network === 'mainnet' ? 'https://toncenter.com/api/v2/jsonRPC' : 'https://testnet.toncenter.com/api/v2/jsonRPC'); + currentNetwork = network; + currentApiBase = tonApiUrl; + emitCallCheckpoint(context, 'initTonWalletKit:constructing-tonwalletkit'); + const chains = tonConnectChain; + if (!chains) { + throw new Error('TON Connect chain constants unavailable'); + } + const chain = network === 'mainnet' ? chains.MAINNET : chains.TESTNET; + + console.log('[walletkitBridge] initTonWalletKit config:', JSON.stringify(config, null, 2)); + + let walletManifest = config?.walletManifest; + console.log('[walletkitBridge] walletManifest from config:', walletManifest); + + if (!walletManifest && config?.bridgeUrl && typeof createWalletManifest === 'function') { + console.log('[walletkitBridge] Creating wallet manifest with bridgeName:', config.bridgeName); + walletManifest = createWalletManifest({ + bridgeUrl: config.bridgeUrl, + name: config.bridgeName ?? 'Wallet', + appName: config.bridgeName ?? 'Wallet', + }); + console.log('[walletkitBridge] Created wallet manifest:', walletManifest); + } + + const kitOptions: Record = { + network: chain, + apiClient: { url: clientEndpoint }, + }; + + if (config?.deviceInfo) { + kitOptions.deviceInfo = config.deviceInfo; + } + + if (walletManifest) { + kitOptions.walletManifest = walletManifest; + } + + const resolvedBridgeUrl = + config?.bridgeUrl ?? (walletManifest && typeof walletManifest === 'object' ? walletManifest.bridgeUrl : undefined); + if (resolvedBridgeUrl) { + kitOptions.bridge = { + bridgeUrl: resolvedBridgeUrl, + }; + } + + if (config?.allowMemoryStorage) { + kitOptions.storage = { + allowMemory: true, + }; + } + + walletKit = new TonWalletKit(kitOptions); + + if (typeof walletKit.ensureInitialized === 'function') { + emitCallCheckpoint(context, 'initTonWalletKit:before-walletKit.ensureInitialized'); + await walletKit.ensureInitialized(); + emitCallCheckpoint(context, 'initTonWalletKit:after-walletKit.ensureInitialized'); + } + + // Events are emitted directly to consumers without storing state + // Consumer (Android/iOS) is responsible for storing events and passing them back on approve/reject + walletKit.onConnectRequest((event: any) => { + emit('connectRequest', event); + }); + walletKit.onTransactionRequest((event: unknown) => { + emit('transactionRequest', event); + }); + walletKit.onSignDataRequest((event: unknown) => { + emit('signDataRequest', event); + }); + walletKit.onDisconnect((event: unknown) => { + console.log('[walletkitBridge] disconnect event', event); + emit('disconnect', event); + }); + + initialized = true; + emitCallCheckpoint(context, 'initTonWalletKit:initialized'); + const readyDetails = { + network, + tonApiUrl, + tonClientEndpoint: clientEndpoint, + }; + emit('ready', readyDetails); + postToNative({ kind: 'ready', ...readyDetails }); + console.log('[walletkitBridge] WalletKit ready'); + emitCallCheckpoint(context, 'initTonWalletKit:ready-dispatched'); + return { ok: true }; +} + +function requireWalletKit() { + if (!initialized || !walletKit) { + throw new Error('WalletKit not initialized'); + } +} + +const api = { + async init(config?: WalletKitBridgeInitConfig, context?: CallContext) { + emitCallCheckpoint(context, 'init:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'init:after-ensureWalletKitLoaded'); + emitCallCheckpoint(context, 'init:before-initTonWalletKit'); + const result = await initTonWalletKit(config, context); + emitCallCheckpoint(context, 'init:after-initTonWalletKit'); + return result; + }, + + async addWalletFromMnemonic( + args: { words: string[]; version: 'v5r1' | 'v4r2'; network?: 'mainnet' | 'testnet' }, + context?: CallContext, + ) { + emitCallCheckpoint(context, 'addWalletFromMnemonic:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'addWalletFromMnemonic:after-ensureWalletKitLoaded'); + requireWalletKit(); + emitCallCheckpoint(context, 'addWalletFromMnemonic:after-requireWalletKit'); + const chains = tonConnectChain; + if (!chains) { + throw new Error('TON Connect chain constants unavailable'); + } + const chain = (args.network || 'testnet') === 'mainnet' ? chains.MAINNET : chains.TESTNET; + const config = createWalletInitConfigMnemonic({ + mnemonic: args.words, + version: args.version, + mnemonicType: 'ton', + network: chain, + }); + emitCallCheckpoint(context, 'addWalletFromMnemonic:before-walletKit.addWallet'); + await walletKit.addWallet(config); + emitCallCheckpoint(context, 'addWalletFromMnemonic:after-walletKit.addWallet'); + return { ok: true }; + }, + + async getWallets(_?: unknown, context?: CallContext) { + emitCallCheckpoint(context, 'getWallets:enter'); + requireWalletKit(); + emitCallCheckpoint(context, 'getWallets:after-requireWalletKit'); + if (typeof walletKit.ensureInitialized === 'function') { + emitCallCheckpoint(context, 'getWallets:before-walletKit.ensureInitialized'); + await walletKit.ensureInitialized(); + emitCallCheckpoint(context, 'getWallets:after-walletKit.ensureInitialized'); + } + const wallets = walletKit.getWallets?.() || []; + emitCallCheckpoint(context, 'getWallets:after-walletKit.getWallets'); + return wallets.map((wallet: any, index: number) => ({ + address: wallet.getAddress(), + publicKey: Array.from(wallet.publicKey as Uint8Array) + .map((b: number) => b.toString(16).padStart(2, '0')) + .join(''), + version: typeof wallet.version === 'string' ? wallet.version : 'unknown', + index, + network: currentNetwork, + })); + }, + + async removeWallet(args: { address: string }, context?: CallContext) { + emitCallCheckpoint(context, 'removeWallet:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'removeWallet:after-ensureWalletKitLoaded'); + requireWalletKit(); + const address = args.address?.trim(); + if (!address) { + throw new Error('Wallet address is required'); + } + const wallet = walletKit.getWallet?.(address); + if (!wallet) { + return { removed: false }; + } + emitCallCheckpoint(context, 'removeWallet:before-walletKit.removeWallet'); + await walletKit.removeWallet(address); + emitCallCheckpoint(context, 'removeWallet:after-walletKit.removeWallet'); + + return { removed: true }; + }, + + async getWalletState(args: { address: string }, context?: CallContext) { + requireWalletKit(); + if (typeof walletKit.ensureInitialized === 'function') { + emitCallCheckpoint(context, 'getWalletState:before-walletKit.ensureInitialized'); + await walletKit.ensureInitialized(); + emitCallCheckpoint(context, 'getWalletState:after-walletKit.ensureInitialized'); + } + const wallet = walletKit.getWallet(args.address); + if (!wallet) throw new Error('Wallet not found'); + emitCallCheckpoint(context, 'getWalletState:before-wallet.getBalance'); + const balance = await wallet.getBalance(); + emitCallCheckpoint(context, 'getWalletState:after-wallet.getBalance'); + console.log('[walletkitBridge] balance type:', typeof balance); + console.log('[walletkitBridge] balance value:', balance); + console.log('[walletkitBridge] balance.toString type:', typeof balance?.toString); + const balanceStr = balance != null && typeof balance.toString === 'function' ? balance.toString() : String(balance); + console.log('[walletkitBridge] balanceStr:', balanceStr); + const transactions = wallet.getTransactions ? await wallet.getTransactions(10) : []; + emitCallCheckpoint(context, 'getWalletState:after-wallet.getTransactions'); + return { balance: balanceStr, transactions }; + }, + + async getRecentTransactions( + args: { address: string; limit?: number }, + context?: CallContext, + ): Promise<{ items: unknown[] }> { + emitCallCheckpoint(context, 'getRecentTransactions:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'getRecentTransactions:after-ensureWalletKitLoaded'); + requireWalletKit(); + const address = args.address?.trim(); + if (!address) { + throw new Error('Wallet address is required'); + } + const wallet = walletKit.getWallet?.(address); + if (!wallet) { + throw new Error(`Wallet not found for address ${address}`); + } + const limit = Number.isFinite(args.limit) && (args.limit as number) > 0 ? Math.floor(args.limit as number) : 10; + + console.log('[walletkitBridge] getRecentTransactions fetching transactions for address:', address); + emitCallCheckpoint(context, 'getRecentTransactions:before-client.getAccountTransactions'); + + // Use wallet.client.getAccountTransactions - address must be an array + const response = await wallet.client.getAccountTransactions({ + address: [address], // Must be an array! + limit, + }); + + // Response has structure: { transactions: [...], address_book: {...} } + const transactions = response?.transactions || []; + console.log('[walletkitBridge] getRecentTransactions fetched:', transactions.length, 'transactions'); + console.log('[walletkitBridge] Address helper available:', !!Address, 'Cell helper available:', !!Cell); + + // Log structure of first transaction + if (transactions.length > 0) { + const firstTx = transactions[0]; + console.log('[walletkitBridge] First tx keys:', Object.keys(firstTx).join(', ')); + if (firstTx.in_msg) { + console.log('[walletkitBridge] in_msg keys:', Object.keys(firstTx.in_msg).join(', ')); + if (firstTx.in_msg.message_content) { + console.log('[walletkitBridge] in_msg.message_content keys:', Object.keys(firstTx.in_msg.message_content).join(', ')); + } + } + } + + // Process transactions to add user-friendly addresses and extract comments + const processedTransactions = transactions.map((tx: any, idx: number) => { + // Convert hash from base64 to hex + if (tx.hash) { + tx.hash_hex = base64ToHex(tx.hash); + } + + // Convert addresses to user-friendly format + if (tx.in_msg?.source) { + const rawAddr = tx.in_msg.source; + const friendlyAddr = toUserFriendlyAddress(rawAddr); + tx.in_msg.source_friendly = friendlyAddr; + if (idx === 0) { + console.log('[walletkitBridge] Converting source address:', rawAddr, '→', friendlyAddr); + } + } + if (tx.in_msg?.destination) { + const rawAddr = tx.in_msg.destination; + const friendlyAddr = toUserFriendlyAddress(rawAddr); + tx.in_msg.destination_friendly = friendlyAddr; + if (idx === 0) { + console.log('[walletkitBridge] Converting destination address:', rawAddr, '→', friendlyAddr); + } + } + + // Process outgoing messages + if (tx.out_msgs && Array.isArray(tx.out_msgs)) { + tx.out_msgs = tx.out_msgs.map((msg: any) => { + const processed = { ...msg }; + if (msg.source) { + processed.source_friendly = toUserFriendlyAddress(msg.source); + } + if (msg.destination) { + processed.destination_friendly = toUserFriendlyAddress(msg.destination); + } + // Try to extract comment from message body + if (msg.message_content?.body) { + const comment = extractTextComment(msg.message_content.body); + if (comment) { + processed.comment = comment; + } + } + return processed; + }); + } + + // Try to extract comment from incoming message + if (tx.in_msg?.message_content?.body) { + const body = tx.in_msg.message_content.body; + if (idx === 0) { + console.log('[walletkitBridge] in_msg.message_content.body exists, type:', typeof body, 'value:', body ? body.substring(0, 100) : 'null'); + } + const comment = extractTextComment(body); + if (comment) { + tx.in_msg.comment = comment; + if (idx === 0) { + console.log('[walletkitBridge] Extracted comment from in_msg:', comment); + } + } else if (idx === 0) { + console.log('[walletkitBridge] No comment extracted from body'); + } + } else if (idx === 0) { + console.log('[walletkitBridge] No in_msg.message_content.body - keys:', tx.in_msg ? Object.keys(tx.in_msg) : 'no in_msg'); + } + + return tx; + }); + + if (processedTransactions.length > 0) { + console.log('[walletkitBridge] First transaction after processing - hash_hex:', processedTransactions[0].hash_hex); + console.log('[walletkitBridge] First transaction after processing - in_msg.source_friendly:', processedTransactions[0].in_msg?.source_friendly); + console.log('[walletkitBridge] First transaction after processing - in_msg.comment:', processedTransactions[0].in_msg?.comment); + } + + if (processedTransactions.length > 0) { + console.log('[walletkitBridge] First transaction sample:', JSON.stringify(processedTransactions[0]).substring(0, 800)); + } + emitCallCheckpoint(context, 'getRecentTransactions:after-client.getAccountTransactions'); + return { items: Array.isArray(processedTransactions) ? processedTransactions : [] }; + }, + + async handleTonConnectUrl(args: unknown, context?: CallContext) { + console.log('[walletkitBridge] handleTonConnectUrl called with args:', args); + emitCallCheckpoint(context, 'handleTonConnectUrl:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'handleTonConnectUrl:after-ensureWalletKitLoaded'); + requireWalletKit(); + emitCallCheckpoint(context, 'handleTonConnectUrl:after-requireWalletKit'); + const url = resolveTonConnectUrl(args); + console.log('[walletkitBridge] resolved URL:', url); + if (!url) { + throw new Error('TON Connect URL is missing'); + } + console.log('[walletkitBridge] calling walletKit.handleTonConnectUrl with:', url); + try { + const result = await walletKit.handleTonConnectUrl(url); + console.log('[walletkitBridge] handleTonConnectUrl result:', result); + return result; + } catch (err) { + console.error('[walletkitBridge] handleTonConnectUrl error:', err); + console.error('[walletkitBridge] error type:', typeof err); + console.error('[walletkitBridge] error message:', err instanceof Error ? err.message : String(err)); + console.error('[walletkitBridge] error stack:', err instanceof Error ? err.stack : 'no stack'); + throw err; + } + }, + + async sendTransaction( + args: { walletAddress: string; toAddress: string; amount: string; comment?: string }, + context?: CallContext, + ) { + emitCallCheckpoint(context, 'sendTransaction:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'sendTransaction:after-ensureWalletKitLoaded'); + requireWalletKit(); + emitCallCheckpoint(context, 'sendTransaction:after-requireWalletKit'); + + const walletAddress = + typeof args.walletAddress === 'string' ? args.walletAddress.trim() : String(args.walletAddress ?? '').trim(); + if (!walletAddress) { + throw new Error('Wallet address is required'); + } + + const toAddress = + typeof args.toAddress === 'string' ? args.toAddress.trim() : String(args.toAddress ?? '').trim(); + if (!toAddress) { + throw new Error('Recipient address is required'); + } + + const amount = + typeof args.amount === 'string' ? args.amount.trim() : String(args.amount ?? '').trim(); + if (!amount) { + throw new Error('Amount is required'); + } + + const wallet = walletKit.getWallet?.(walletAddress); + if (!wallet) { + throw new Error(`Wallet not found for address ${walletAddress}`); + } + + const transferParams: Record = { + toAddress, + amount, + }; + + const comment = typeof args.comment === 'string' ? args.comment.trim() : ''; + if (comment) { + transferParams.comment = comment; + } + + emitCallCheckpoint(context, 'sendTransaction:before-wallet.createTransferTonTransaction'); + const transaction = await wallet.createTransferTonTransaction(transferParams); + emitCallCheckpoint(context, 'sendTransaction:after-wallet.createTransferTonTransaction'); + + // Add comment to transaction messages for UI display (doesn't affect blockchain encoding) + if (comment && transaction.messages && Array.isArray(transaction.messages)) { + transaction.messages = transaction.messages.map((msg: any) => ({ + ...msg, + comment: comment, + })); + } + + let preview: unknown = null; + if (typeof wallet.getTransactionPreview === 'function') { + try { + emitCallCheckpoint(context, 'sendTransaction:before-wallet.getTransactionPreview'); + const previewResult = await wallet.getTransactionPreview(transaction); + preview = previewResult?.preview ?? previewResult; + emitCallCheckpoint(context, 'sendTransaction:after-wallet.getTransactionPreview'); + } catch (error) { + console.warn('[walletkitBridge] getTransactionPreview failed', error); + } + } + + // handleNewTransaction triggers onTransactionRequest event + // Android app should listen to transactionRequest event to show confirmation UI with fee details + // User then calls approveTransactionRequest or rejectTransactionRequest + emitCallCheckpoint(context, 'sendTransaction:before-walletKit.handleNewTransaction'); + await walletKit.handleNewTransaction(wallet, transaction); + emitCallCheckpoint(context, 'sendTransaction:after-walletKit.handleNewTransaction'); + + // This returns immediately after queuing the transaction request + // The actual transaction is sent only when approveTransactionRequest is called + return { + success: true, + transaction, + preview, + }; + }, + + async approveConnectRequest(args: { event: any; walletAddress: string }, context?: CallContext) { + emitCallCheckpoint(context, 'approveConnectRequest:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'approveConnectRequest:after-ensureWalletKitLoaded'); + requireWalletKit(); + emitCallCheckpoint(context, 'approveConnectRequest:after-requireWalletKit'); + + // Ensure bridge is initialized before approving connect request + if (typeof walletKit.ensureInitialized === 'function') { + console.log('ensureInitialized'); + emitCallCheckpoint(context, 'approveConnectRequest:before-walletKit.ensureInitialized'); + await walletKit.ensureInitialized(); + console.log('await this.initializationPromise'); + emitCallCheckpoint(context, 'approveConnectRequest:after-walletKit.ensureInitialized'); + console.log('ensureInitialized done'); + } + + const event = args.event; + if (!event) { + throw new Error('Connect request event is required'); + } + const wallet = walletKit.getWallet?.(args.walletAddress); + if (!wallet) { + throw new Error('Wallet not found'); + } + const resolvedAddress = + (typeof wallet.getAddress === 'function' ? wallet.getAddress() : wallet.address) || args.walletAddress; + event.wallet = wallet; + event.walletAddress = resolvedAddress; + emitCallCheckpoint(context, 'approveConnectRequest:before-walletKit.approveConnectRequest'); + const result = await walletKit.approveConnectRequest(event); + if (!result?.success) { + const message = result?.message || 'Failed to approve connect request'; + throw new Error(message); + } + emitCallCheckpoint(context, 'approveConnectRequest:after-walletKit.approveConnectRequest'); + return result; + }, + + async rejectConnectRequest(args: { event: any; reason?: string }, context?: CallContext) { + emitCallCheckpoint(context, 'rejectConnectRequest:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'rejectConnectRequest:after-ensureWalletKitLoaded'); + requireWalletKit(); + emitCallCheckpoint(context, 'rejectConnectRequest:after-requireWalletKit'); + const event = args.event; + if (!event) { + throw new Error('Connect request event is required'); + } + const result = await walletKit.rejectConnectRequest(event, args.reason); + if (!result?.success) { + const message = result?.message || 'Failed to reject connect request'; + throw new Error(message); + } + return result; + }, + + async approveTransactionRequest(args: { event: any }, context?: CallContext) { + emitCallCheckpoint(context, 'approveTransactionRequest:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'approveTransactionRequest:after-ensureWalletKitLoaded'); + requireWalletKit(); + const event = args.event; + if (!event) { + throw new Error('Transaction request event is required'); + } + const result = await walletKit.approveTransactionRequest(event); + return result; + }, + + async rejectTransactionRequest(args: { event: any; reason?: string }, context?: CallContext) { + emitCallCheckpoint(context, 'rejectTransactionRequest:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'rejectTransactionRequest:after-ensureWalletKitLoaded'); + requireWalletKit(); + const event = args.event; + if (!event) { + throw new Error('Transaction request event is required'); + } + const result = await walletKit.rejectTransactionRequest(event, args.reason); + return result; + }, + + async approveSignDataRequest(args: { event: any }, context?: CallContext) { + emitCallCheckpoint(context, 'approveSignDataRequest:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'approveSignDataRequest:after-ensureWalletKitLoaded'); + requireWalletKit(); + const event = args.event; + if (!event) { + throw new Error('Sign data request event is required'); + } + console.log('[bridge] Approving sign data request with event:', JSON.stringify(event, null, 2)); + const result = await walletKit.signDataRequest(event); + console.log('[bridge] Sign data result:', JSON.stringify(result, null, 2)); + return result; + }, + + async rejectSignDataRequest(args: { event: any; reason?: string }, context?: CallContext) { + emitCallCheckpoint(context, 'rejectSignDataRequest:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'rejectSignDataRequest:after-ensureWalletKitLoaded'); + requireWalletKit(); + const event = args.event; + if (!event) { + throw new Error('Sign data request event is required'); + } + const result = await walletKit.rejectSignDataRequest(event, args.reason); + return result; + }, + + async listSessions(_?: unknown, context?: CallContext) { + emitCallCheckpoint(context, 'listSessions:enter'); + requireWalletKit(); + let sessions: any[] = []; + if (typeof walletKit.listSessions === 'function') { + emitCallCheckpoint(context, 'listSessions:before-walletKit.listSessions'); + try { + sessions = (await walletKit.listSessions()) ?? []; + } catch (error) { + console.error('[walletkitBridge] walletKit.listSessions failed', error); + throw error; + } + emitCallCheckpoint(context, 'listSessions:after-walletKit.listSessions'); + } + return sessions.map((session: any) => { + const sessionId = session.sessionId || session.id; + return { + sessionId, + dAppName: session.dAppName || session.name || '', + walletAddress: session.walletAddress, + dAppUrl: session.dAppUrl || session.url || null, + manifestUrl: session.manifestUrl || null, + iconUrl: session.dAppIconUrl || session.iconUrl || null, + createdAt: serializeDate(session.createdAt), + lastActivity: serializeDate(session.lastActivity), + }; + }); + }, + + async disconnectSession(args?: { sessionId?: string }, context?: CallContext) { + emitCallCheckpoint(context, 'disconnectSession:before-ensureWalletKitLoaded'); + await ensureWalletKitLoaded(); + emitCallCheckpoint(context, 'disconnectSession:after-ensureWalletKitLoaded'); + requireWalletKit(); + if (typeof walletKit.disconnect !== 'function') { + throw new Error('walletKit.disconnect is not available'); + } + emitCallCheckpoint(context, 'disconnectSession:before-walletKit.disconnect'); + await walletKit.disconnect(args?.sessionId); + emitCallCheckpoint(context, 'disconnectSession:after-walletKit.disconnect'); + return { ok: true }; + }, + + /** + * Test/Demo API: Inject a sign data request for testing purposes. + * This simulates receiving a sign data request from a dApp. + */ + async injectSignDataRequest(requestData: any, context?: CallContext) { + emitCallCheckpoint(context, 'injectSignDataRequest:start'); + if (requestData && requestData.id) { + // Parse the SignDataPayload from params[0] + let rawPayload: any = null; + if (requestData.params && Array.isArray(requestData.params) && requestData.params[0]) { + try { + rawPayload = typeof requestData.params[0] === 'string' + ? JSON.parse(requestData.params[0]) + : requestData.params[0]; + } catch (e) { + console.error('[bridge] Failed to parse signData params[0]:', e); + return { success: false, error: 'Invalid params' }; + } + } + + if (!rawPayload) { + console.error('[bridge] No signData payload found in params'); + return { success: false, error: 'No payload' }; + } + + // Convert from schema_crc format to TON Connect SignDataPayload format + let signDataPayload: any; + if (rawPayload.schema_crc === 0) { + // Text/comment type + signDataPayload = { type: 'text', text: rawPayload.payload }; + } else if (rawPayload.schema_crc === 1) { + // Binary type + signDataPayload = { type: 'binary', bytes: rawPayload.payload }; + } else if (rawPayload.schema_crc === 2) { + // Cell type + const schema = + typeof rawPayload.schema === 'string' + ? rawPayload.schema + : typeof rawPayload.schemaString === 'string' + ? rawPayload.schemaString + : undefined; + if (!schema) { + console.error('[bridge] Cell payload is missing schema string'); + return { success: false, error: 'Cell payload requires schema string' }; + } + signDataPayload = { type: 'cell', cell: rawPayload.payload, schema }; + } else { + // Use payload as-is if already in correct format + signDataPayload = rawPayload; + } + + // Validate the converted payload + console.log('[bridge] Validating signDataPayload:', JSON.stringify(signDataPayload)); + if (!signDataPayload || !signDataPayload.type) { + console.error('[bridge] Invalid signDataPayload - missing type'); + return { success: false, error: 'Invalid payload structure' }; + } + + // Create preview based on type + let preview: any; + if (signDataPayload.type === 'text') { + if (!signDataPayload.text) { + console.error('[bridge] Text payload missing text field'); + return { success: false, error: 'Invalid text payload' }; + } + preview = { kind: 'text', content: signDataPayload.text }; + } else if (signDataPayload.type === 'binary') { + if (!signDataPayload.bytes) { + console.error('[bridge] Binary payload missing bytes field'); + return { success: false, error: 'Invalid binary payload' }; + } + preview = { kind: 'binary', content: signDataPayload.bytes }; + } else if (signDataPayload.type === 'cell') { + if (!signDataPayload.cell || !signDataPayload.schema) { + console.error('[bridge] Cell payload missing cell or schema field'); + return { success: false, error: 'Invalid cell payload - cell and schema required' }; + } + preview = { kind: 'cell', content: signDataPayload.cell, schema: signDataPayload.schema }; + } else { + console.error('[bridge] Unknown payload type:', signDataPayload.type); + return { success: false, error: 'Unknown payload type' }; + } + + // Create a proper EventSignDataRequest with request and preview fields + const processedEvent = { + ...requestData, + request: signDataPayload, + preview: preview, + isLocal: true, // Mark as local so bridge response is skipped + }; + + console.log('[bridge] Processed injected sign data request:', JSON.stringify(processedEvent, null, 2)); + + // Emit directly - consumer is responsible for storing the event + emit('signDataRequest', processedEvent); + } + emitCallCheckpoint(context, 'injectSignDataRequest:complete'); + return { success: true }; + }, +}; + +function serializeDate(value: unknown): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + const timestamp = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(timestamp)) return null; + return new Date(timestamp).toISOString(); +} + +window.walletkitBridge = api; + +postToNative({ + kind: 'ready', + network: currentNetwork, + tonApiUrl: currentApiBase, +}); +console.log('[walletkitBridge] bootstrap complete'); diff --git a/apps/androidkit/js/src/globals.d.ts b/apps/androidkit/js/src/globals.d.ts new file mode 100644 index 000000000..7ff4f5898 --- /dev/null +++ b/apps/androidkit/js/src/globals.d.ts @@ -0,0 +1,10 @@ +// Re-export types from types.ts for backwards compatibility +import type { + WalletKitBridgeEvent, + WalletKitBridgeInitConfig, + AndroidBridgeType, + WalletKitNativeBridgeType, + WalletKitBridgeApi +} from './types'; + +// No need to re-declare these - they're already in types.ts diff --git a/apps/androidkit/js/src/index.ts b/apps/androidkit/js/src/index.ts new file mode 100644 index 000000000..deea111b9 --- /dev/null +++ b/apps/androidkit/js/src/index.ts @@ -0,0 +1,5 @@ +import { setupPolyfills } from './setupPolyfills'; + +setupPolyfills(); + +void import('./bridge'); diff --git a/apps/androidkit/js/src/setupPolyfills.ts b/apps/androidkit/js/src/setupPolyfills.ts new file mode 100644 index 000000000..83712f3f7 --- /dev/null +++ b/apps/androidkit/js/src/setupPolyfills.ts @@ -0,0 +1,64 @@ +import textEncoder from './textEncoder'; +import { Buffer } from 'buffer'; +import { URL, URLSearchParams } from 'whatwg-url'; + +type AnyGlobal = typeof globalThis & Record; + +function applyTextEncoder(target: AnyGlobal) { + try { + textEncoder(target as any); + } catch (err) { + console.error('[walletkitBridge] Failed to apply TextEncoder polyfill', err); + } +} + +function ensureFetch(target: AnyGlobal) { + if (typeof target.fetch === 'undefined' && typeof window !== 'undefined' && typeof window.fetch === 'function') { + target.fetch = window.fetch.bind(window); + } +} + +function ensureAbortController(target: AnyGlobal) { + if (typeof target.AbortController === 'undefined') { + class PolyfillAbortController implements AbortController { + signal = { + aborted: false, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return true; }, + onabort: null, + reason: undefined, + throwIfAborted() {}, + } as AbortSignal; + + abort() { + (this.signal as any).aborted = true; + } + } + target.AbortController = PolyfillAbortController as any; + } +} + +export function setupPolyfills() { + const scopes: Array = [ + typeof globalThis !== 'undefined' ? (globalThis as unknown as AnyGlobal) : undefined, + typeof window !== 'undefined' ? (window as unknown as AnyGlobal) : undefined, + typeof self !== 'undefined' ? (self as unknown as AnyGlobal) : undefined, + ]; + + scopes.forEach((scope) => { + if (!scope) return; + applyTextEncoder(scope); + ensureFetch(scope); + ensureAbortController(scope); + if (typeof scope.Buffer === 'undefined') { + scope.Buffer = Buffer as any; + } + if (typeof scope.URL === 'undefined') { + scope.URL = URL as any; + } + if (typeof scope.URLSearchParams === 'undefined') { + scope.URLSearchParams = URLSearchParams as any; + } + }); +} diff --git a/apps/androidkit/js/src/textEncoder.ts b/apps/androidkit/js/src/textEncoder.ts new file mode 100644 index 000000000..2ce9e3c9c --- /dev/null +++ b/apps/androidkit/js/src/textEncoder.ts @@ -0,0 +1,454 @@ +/* eslint-disable */ +/** @define {boolean} */ +var ENCODEINTO_BUILD = false; + +export default (function(window: any){ + "use strict"; + //var log = Math.log; + //var LN2 = Math.LN2; + //var clz32 = Math.clz32 || function(x) {return 31 - log(x >> 0) / LN2 | 0}; + var fromCharCode = String.fromCharCode; + var Object_prototype_toString = ({}).toString; + var sharedArrayBufferString = Object_prototype_toString.call(window["SharedArrayBuffer"]); + var undefinedObjectString = Object_prototype_toString(); + var NativeUint8Array = window.Uint8Array; + var patchedU8Array = NativeUint8Array || Array; + var nativeArrayBuffer = NativeUint8Array ? ArrayBuffer : patchedU8Array; + var arrayBuffer_isView = nativeArrayBuffer.isView || function(x: any) {return x && "length" in x}; + var arrayBufferString = Object_prototype_toString.call(nativeArrayBuffer.prototype); + var window_encodeURIComponent = encodeURIComponent; + var window_parseInt = parseInt; + var TextEncoderPrototype = TextEncoder["prototype"]; + var GlobalTextEncoder = window["TextEncoder"]; + var decoderRegexp = /[\xc0-\xff][\x80-\xbf]+|[\x80-\xff]/g; + var encoderRegexp = /[\x80-\uD7ff\uDC00-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g; + var tmpBufferU16 = new (NativeUint8Array ? Uint16Array : patchedU8Array)(32); + var globalTextEncoderPrototype: any; + var globalTextEncoderInstance: any; + + /*function decoderReplacer(encoded) { + var cp0 = encoded.charCodeAt(0), codePoint=0x110000, i=0, stringLen=encoded.length|0, result=""; + switch(cp0 >> 4) { + // no 1 byte sequences + case 12: + case 13: + codePoint = ((cp0 & 0x1F) << 6) | (encoded.charCodeAt(1) & 0x3F); + i = codePoint < 0x80 ? 0 : 2; + break; + case 14: + codePoint = ((cp0 & 0x0F) << 12) | ((encoded.charCodeAt(1) & 0x3F) << 6) | (encoded.charCodeAt(2) & 0x3F); + i = codePoint < 0x800 ? 0 : 3; + break; + case 15: + if ((cp0 >> 3) === 30) { + codePoint = ((cp0 & 0x07) << 18) | ((encoded.charCodeAt(1) & 0x3F) << 12) | ((encoded.charCodeAt(2) & 0x3F) << 6) | (encoded.charCodeAt(3) & 0x3F); + i = codePoint < 0x10000 ? 0 : 4; + } + } + if (i) { + if (stringLen < i) { + i = 0; + } else if (codePoint < 0x10000) { // BMP code point + result = fromCharCode(codePoint); + } else if (codePoint < 0x110000) { + codePoint = codePoint - 0x10080|0;//- 0x10000|0; + result = fromCharCode( + (codePoint >> 10) + 0xD800|0, // highSurrogate + (codePoint & 0x3ff) + 0xDC00|0 // lowSurrogate + ); + } else i = 0; // to fill it in with INVALIDs + } + + for (; i < stringLen; i=i+1|0) result += "\ufffd"; // fill rest with replacement character + + return result; + }*/ + function TextDecoder(){}; + TextDecoder["prototype"]["decode"] = function(inputArrayOrBuffer: any){ + var inputAs8 = inputArrayOrBuffer, asObjectString; + if (!arrayBuffer_isView(inputAs8)) { + asObjectString = Object_prototype_toString.call(inputAs8); + if (asObjectString !== arrayBufferString && asObjectString !== sharedArrayBufferString && asObjectString !== undefinedObjectString) + throw TypeError("Failed to execute 'decode' on 'TextDecoder': The provided value is not of type '(ArrayBuffer or ArrayBufferView)'"); + inputAs8 = NativeUint8Array ? new patchedU8Array(inputAs8) : inputAs8 || []; + } + + var resultingString="", tmpStr="", index=0, len=inputAs8.length|0, lenMinus32=len-32|0, nextEnd=0, nextStop=0, cp0=0, codePoint=0, minBits=0, cp1=0, pos=0, tmp=-1; + // Note that tmp represents the 2nd half of a surrogate pair incase a surrogate gets divided between blocks + for (; index < len; ) { + nextEnd = index <= lenMinus32 ? 32 : len - index|0; + for (; pos < nextEnd; index=index+1|0, pos=pos+1|0) { + cp0 = inputAs8[index] & 0xff; + switch(cp0 >> 4) { + case 15: + cp1 = inputAs8[index=index+1|0] & 0xff; + if ((cp1 >> 6) !== 0b10 || 0b11110111 < cp0) { + index = index - 1|0; + break; + } + codePoint = ((cp0 & 0b111) << 6) | (cp1 & 0b00111111); + minBits = 5; // 20 ensures it never passes -> all invalid replacements + cp0 = 0x100; // keep track of th bit size + case 14: + cp1 = inputAs8[index=index+1|0] & 0xff; + codePoint <<= 6; + codePoint |= ((cp0 & 0b1111) << 6) | (cp1 & 0b00111111); + minBits = (cp1 >> 6) === 0b10 ? minBits + 4|0 : 24; // 24 ensures it never passes -> all invalid replacements + cp0 = (cp0 + 0x100) & 0x300; // keep track of th bit size + case 13: + case 12: + cp1 = inputAs8[index=index+1|0] & 0xff; + codePoint <<= 6; + codePoint |= ((cp0 & 0b11111) << 6) | cp1 & 0b00111111; + minBits = minBits + 7|0; + + // Now, process the code point + if (index < len && (cp1 >> 6) === 0b10 && (codePoint >> minBits) && codePoint < 0x110000) { + cp0 = codePoint; + codePoint = codePoint - 0x10000|0; + if (0 <= codePoint/*0xffff < codePoint*/) { // BMP code point + //nextEnd = nextEnd - 1|0; + + tmp = (codePoint >> 10) + 0xD800|0; // highSurrogate + cp0 = (codePoint & 0x3ff) + 0xDC00|0; // lowSurrogate (will be inserted later in the switch-statement) + + if (pos < 31) { // notice 31 instead of 32 + tmpBufferU16[pos] = tmp; + pos = pos + 1|0; + tmp = -1; + } else {// else, we are at the end of the inputAs8 and let tmp0 be filled in later on + // NOTE that cp1 is being used as a temporary variable for the swapping of tmp with cp0 + cp1 = tmp; + tmp = cp0; + cp0 = cp1; + } + } else nextEnd = nextEnd + 1|0; // because we are advancing i without advancing pos + } else { + // invalid code point means replacing the whole thing with null replacement characters + cp0 >>= 8; + index = index - cp0 - 1|0; // reset index back to what it was before + cp0 = 0xfffd; + } + + + // Finally, reset the variables for the next go-around + minBits = 0; + codePoint = 0; + nextEnd = index <= lenMinus32 ? 32 : len - index|0; + /*case 11: + case 10: + case 9: + case 8: + codePoint ? codePoint = 0 : cp0 = 0xfffd; // fill with invalid replacement character + case 7: + case 6: + case 5: + case 4: + case 3: + case 2: + case 1: + case 0: + tmpBufferU16[pos] = cp0; + continue;*/ + default: + tmpBufferU16[pos] = cp0; // fill with invalid replacement character + continue; + case 11: + case 10: + case 9: + case 8: + } + tmpBufferU16[pos] = 0xfffd; // fill with invalid replacement character + } + tmpStr += fromCharCode( + tmpBufferU16[ 0], tmpBufferU16[ 1], tmpBufferU16[ 2], tmpBufferU16[ 3], tmpBufferU16[ 4], tmpBufferU16[ 5], tmpBufferU16[ 6], tmpBufferU16[ 7], + tmpBufferU16[ 8], tmpBufferU16[ 9], tmpBufferU16[10], tmpBufferU16[11], tmpBufferU16[12], tmpBufferU16[13], tmpBufferU16[14], tmpBufferU16[15], + tmpBufferU16[16], tmpBufferU16[17], tmpBufferU16[18], tmpBufferU16[19], tmpBufferU16[20], tmpBufferU16[21], tmpBufferU16[22], tmpBufferU16[23], + tmpBufferU16[24], tmpBufferU16[25], tmpBufferU16[26], tmpBufferU16[27], tmpBufferU16[28], tmpBufferU16[29], tmpBufferU16[30], tmpBufferU16[31] + ); + if (pos < 32) tmpStr = tmpStr.slice(0, pos-32|0);//-(32-pos)); + if (index < len) { + //fromCharCode.apply(0, tmpBufferU16 : NativeUint8Array ? tmpBufferU16.subarray(0,pos) : tmpBufferU16.slice(0,pos)); + tmpBufferU16[0] = tmp; + pos = (~tmp) >>> 31;//tmp !== -1 ? 1 : 0; + tmp = -1; + + if (tmpStr.length < resultingString.length) continue; + } else if (tmp !== -1) { + tmpStr += fromCharCode(tmp); + } + + resultingString += tmpStr; + tmpStr = ""; + } + + return resultingString; + } + ////////////////////////////////////////////////////////////////////////////////////// + function encoderReplacer(nonAsciiChars: any){ + // make the UTF string into a binary UTF-8 encoded string + var point = nonAsciiChars.charCodeAt(0)|0; + if (0xD800 <= point) { + if (point <= 0xDBFF) { + var nextcode = nonAsciiChars.charCodeAt(1)|0; // defaults to 0 when NaN, causing null replacement character + + if (0xDC00 <= nextcode && nextcode <= 0xDFFF) { + //point = ((point - 0xD800)<<10) + nextcode - 0xDC00 + 0x10000|0; + point = (point<<10) + nextcode - 0x35fdc00|0; + if (point > 0xffff) + return fromCharCode( + (0x1e/*0b11110*/<<3) | (point>>18), + (0x2/*0b10*/<<6) | ((point>>12)&0x3f/*0b00111111*/), + (0x2/*0b10*/<<6) | ((point>>6)&0x3f/*0b00111111*/), + (0x2/*0b10*/<<6) | (point&0x3f/*0b00111111*/) + ); + } else point = 65533/*0b1111111111111101*/;//return '\xEF\xBF\xBD';//fromCharCode(0xef, 0xbf, 0xbd); + } else if (point <= 0xDFFF) { + point = 65533/*0b1111111111111101*/;//return '\xEF\xBF\xBD';//fromCharCode(0xef, 0xbf, 0xbd); + } + } + /*if (point <= 0x007f) return nonAsciiChars; + else */if (point <= 0x07ff) { + return fromCharCode((0x6<<5)|(point>>6), (0x2<<6)|(point&0x3f)); + } else return fromCharCode( + (0xe/*0b1110*/<<4) | (point>>12), + (0x2/*0b10*/<<6) | ((point>>6)&0x3f/*0b00111111*/), + (0x2/*0b10*/<<6) | (point&0x3f/*0b00111111*/) + ); + } + function TextEncoder(){}; + TextEncoderPrototype["encode"] = function(inputString: any){ + // 0xc0 => 0b11000000; 0xff => 0b11111111; 0xc0-0xff => 0b11xxxxxx + // 0x80 => 0b10000000; 0xbf => 0b10111111; 0x80-0xbf => 0b10xxxxxx + var encodedString = inputString === void 0 ? "" : ("" + inputString), len=encodedString.length|0; + var result=new patchedU8Array((len << 1) + 8|0), tmpResult; + var i=0, pos=0, point=0, nextcode=0; + var upgradededArraySize=!NativeUint8Array; // normal arrays are auto-expanding + for (i=0; i>6); + result[pos=pos+1|0] = (0x2<<6)|(point&0x3f); + } else { + widenCheck: { + if (0xD800 <= point) { + if (point <= 0xDBFF) { + nextcode = encodedString.charCodeAt(i=i+1|0)|0; // defaults to 0 when NaN, causing null replacement character + + if (0xDC00 <= nextcode && nextcode <= 0xDFFF) { + //point = ((point - 0xD800)<<10) + nextcode - 0xDC00 + 0x10000|0; + point = (point<<10) + nextcode - 0x35fdc00|0; + if (point > 0xffff) { + result[pos] = (0x1e/*0b11110*/<<3) | (point>>18); + result[pos=pos+1|0] = (0x2/*0b10*/<<6) | ((point>>12)&0x3f/*0b00111111*/); + result[pos=pos+1|0] = (0x2/*0b10*/<<6) | ((point>>6)&0x3f/*0b00111111*/); + result[pos=pos+1|0] = (0x2/*0b10*/<<6) | (point&0x3f/*0b00111111*/); + continue; + } + break widenCheck; + } + point = 65533/*0b1111111111111101*/;//return '\xEF\xBF\xBD';//fromCharCode(0xef, 0xbf, 0xbd); + } else if (point <= 0xDFFF) { + point = 65533/*0b1111111111111101*/;//return '\xEF\xBF\xBD';//fromCharCode(0xef, 0xbf, 0xbd); + } + } + if (!upgradededArraySize && (i << 1) < pos && (i << 1) < (pos - 7|0)) { + upgradededArraySize = true; + tmpResult = new patchedU8Array(len * 3); + tmpResult.set( result ); + result = tmpResult; + } + } + result[pos] = (0xe/*0b1110*/<<4) | (point>>12); + result[pos=pos+1|0] =(0x2/*0b10*/<<6) | ((point>>6)&0x3f/*0b00111111*/); + result[pos=pos+1|0] =(0x2/*0b10*/<<6) | (point&0x3f/*0b00111111*/); + } + } + return NativeUint8Array ? result.subarray(0, pos) : result.slice(0, pos); + }; + function polyfill_encodeInto(inputString: any, u8Arr: any) { + var encodedString = inputString === void 0 ? "" : ("" + inputString).replace(encoderRegexp, encoderReplacer); + var len=encodedString.length|0, i=0, char=0, read=0, u8ArrLen = u8Arr.length|0, inputLength=inputString.length|0; + if (u8ArrLen < len) len=u8ArrLen; + putChars: { + for (; i> 4) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + read = read + 1|0; + // extension points: + case 8: + case 9: + case 10: + case 11: + break; + case 12: + case 13: + if ((i+1|0) < u8ArrLen) { + read = read + 1|0; + break; + } + case 14: + if ((i+2|0) < u8ArrLen) { + //if (!(char === 0xEF && encodedString.substr(i+1|0,2) === "\xBF\xBD")) + read = read + 1|0; + break; + } + case 15: + if ((i+3|0) < u8ArrLen) { + read = read + 1|0; + break; + } + default: + break putChars; + } + //read = read + ((char >> 6) !== 2) |0; + u8Arr[i] = char; + } + } + return {"written": i, "read": inputLength < read ? inputLength : read}; + // 0xc0 => 0b11000000; 0xff => 0b11111111; 0xc0-0xff => 0b11xxxxxx + // 0x80 => 0b10000000; 0xbf => 0b10111111; 0x80-0xbf => 0b10xxxxxx + /*var encodedString = typeof inputString == "string" ? inputString : inputString === void 0 ? "" : "" + inputString; + var encodedLen = encodedString.length|0, u8LenLeft=u8Arr.length|0; + var i=-1, read=-1, code=0, point=0, nextcode=0; + tryFast: if (2 < encodedLen && encodedLen < (u8LenLeft >> 1)) { + // Skip the normal checks because we can almost certainly fit the string inside the existing buffer + while (1) { // make the UTF string into a binary UTF-8 encoded string + point = encodedString.charCodeAt(read = read + 1|0)|0; + + if (point <= 0x007f) { + if (point === 0 && encodedLen <= read) { + read = read - 1|0; + break; // we have reached the end of the string + } + u8Arr[i=i+1|0] = point; + } else if (point <= 0x07ff) { + u8Arr[i=i+1|0] = (0x6<<5)|(point>>6); + u8Arr[i=i+1|0] = (0x2<<6)|(point&0x3f); + } else { + if (0xD800 <= point && point <= 0xDBFF) { + nextcode = encodedString.charCodeAt(read)|0; // defaults to 0 when NaN, causing null replacement character + + if (0xDC00 <= nextcode && nextcode <= 0xDFFF) { + read = read + 1|0; + //point = ((point - 0xD800)<<10) + nextcode - 0xDC00 + 0x10000|0; + point = (point<<10) + nextcode - 0x35fdc00|0; + if (point > 0xffff) { + u8Arr[i=i+1|0] = (0x1e<<3) | (point>>18); + u8Arr[i=i+1|0] = (0x2<<6) | ((point>>12)&0x3f); + u8Arr[i=i+1|0] = (0x2<<6) | ((point>>6)&0x3f); + u8Arr[i=i+1|0] = (0x2<<6) | (point&0x3f); + continue; + } + } else if (nextcode === 0 && encodedLen <= read) { + break; // we have reached the end of the string + } else { + point = 65533;//0b1111111111111101; // invalid replacement character + } + } + u8Arr[i=i+1|0] = (0xe<<4) | (point>>12); + u8Arr[i=i+1|0] = (0x2<<6) | ((point>>6)&0x3f); + u8Arr[i=i+1|0] = (0x2<<6) | (point&0x3f); + if (u8LenLeft < (i + ((encodedLen - read) << 1)|0)) { + // These 3x chars are the only way to inflate the size to 3x + u8LenLeft = u8LenLeft - i|0; + break tryFast; + } + } + } + u8LenLeft = 0; // skip the next for-loop + } + + + for (; 0 < u8LenLeft; ) { // make the UTF string into a binary UTF-8 encoded string + point = encodedString.charCodeAt(read = read + 1|0)|0; + + if (point <= 0x007f) { + if (point === 0 && encodedLen <= read) { + read = read - 1|0; + break; // we have reached the end of the string + } + u8LenLeft = u8LenLeft - 1|0; + u8Arr[i=i+1|0] = point; + } else if (point <= 0x07ff) { + u8LenLeft = u8LenLeft - 2|0; + if (0 <= u8LenLeft) { + u8Arr[i=i+1|0] = (0x6<<5)|(point>>6); + u8Arr[i=i+1|0] = (0x2<<6)|(point&0x3f); + } + } else { + if (0xD800 <= point && point <= 0xDBFF) { + nextcode = encodedString.charCodeAt(read = read + 1|0)|0; // defaults to 0 when NaN, causing null replacement character + + if (0xDC00 <= nextcode) { + if (nextcode <= 0xDFFF) { + read = read + 1|0; + //point = ((point - 0xD800)<<10) + nextcode - 0xDC00 + 0x10000|0; + point = (point<<10) + nextcode - 0x35fdc00|0; + if (point > 0xffff) { + u8LenLeft = u8LenLeft - 4|0; + if (0 <= u8LenLeft) { + u8Arr[i=i+1|0] = (0x1e<<3) | (point>>18); + u8Arr[i=i+1|0] = (0x2<<6) | ((point>>12)&0x3f); + u8Arr[i=i+1|0] = (0x2<<6) | ((point>>6)&0x3f); + u8Arr[i=i+1|0] = (0x2<<6) | (point&0x3f); + } + continue; + } + } else if (point <= 0xDFFF) { + point = 65533/*0b1111111111111101*\/;//return '\xEF\xBF\xBD';//fromCharCode(0xef, 0xbf, 0xbd); + } + } else if (nextcode === 0 && encodedLen <= read) { + break; // we have reached the end of the string + } else { + point = 65533;//0b1111111111111101; // invalid replacement character + } + } + u8LenLeft = u8LenLeft - 3|0; + if (0 <= u8LenLeft) { + u8Arr[i=i+1|0] = (0xe<<<4) | (point>>12); + u8Arr[i=i+1|0] = (0x2<<6) | ((point>>6)&0x3f); + u8Arr[i=i+1|0] = (0x2<<6) | (point&0x3f); + } + } + } + return {"read": read < 0 ? 0 : u8LenLeft < 0 ? read : read+1|0, "written": i < 0 ? 0 : i+1|0};*/ + }; + if (ENCODEINTO_BUILD) { + TextEncoderPrototype["encodeInto"] = polyfill_encodeInto; + } + + if (!GlobalTextEncoder) { + window["TextDecoder"] = TextDecoder; + window["TextEncoder"] = TextEncoder; + } else if (ENCODEINTO_BUILD && !(globalTextEncoderPrototype = GlobalTextEncoder["prototype"])["encodeInto"]) { + globalTextEncoderInstance = new GlobalTextEncoder; + globalTextEncoderPrototype["encodeInto"] = function(string: any, u8arr: any) { + // Unfortunately, there's no way I can think of to quickly extract the number of bits written and the number of bytes read and such + var strLen = string.length|0, u8Len = u8arr.length|0; + if (strLen < (u8Len >> 1)) { // in most circumstances, this means its safe. there are still edge-cases which are possible + // in many circumstances, we can use the faster native TextEncoder + var res8 = globalTextEncoderInstance["encode"](string); + var res8Len = res8.length|0; + if (res8Len < u8Len) { // if we dont have to worry about read/written + u8arr.set( res8 ); // every browser that supports TextEncoder also supports typedarray.prototype.set + return { + "read": strLen, + "written": res8.length|0 + }; + } + } + return polyfill_encodeInto(string, u8arr); + }; + } +}) \ No newline at end of file diff --git a/apps/androidkit/js/src/types.ts b/apps/androidkit/js/src/types.ts new file mode 100644 index 000000000..3592854f2 --- /dev/null +++ b/apps/androidkit/js/src/types.ts @@ -0,0 +1,56 @@ +// Module declarations for external dependencies +// Note: These help TypeScript understand modules that don't have type definitions +declare module 'whatwg-url'; +declare module 'buffer'; + +// Bridge event types +export type WalletKitBridgeEvent = { + type: 'ready' + | 'connectRequest' + | 'transactionRequest' + | 'signDataRequest' + | 'disconnect' + | string; + data?: any; +}; + +// Bridge configuration +export type WalletKitBridgeInitConfig = { + network?: 'mainnet' | 'testnet'; + apiUrl?: string; + apiBaseUrl?: string; + tonApiUrl?: string; + tonClientEndpoint?: string; + bridgeUrl?: string; + bridgeName?: string; + allowMemoryStorage?: boolean; + walletManifest?: any; + deviceInfo?: any; +}; + +// Bridge interfaces +export interface AndroidBridgeType { + postMessage: (json: string) => void; +} + +export interface WalletKitNativeBridgeType { + postMessage: (json: string) => void; +} + +export interface WalletKitBridgeApi { + init: (config?: WalletKitBridgeInitConfig) => Promise; + addWalletFromMnemonic: (args: { words: string[]; version: 'v5r1' | 'v4r2'; network?: 'mainnet' | 'testnet' }) => Promise; + getWallets: () => Promise; + getWalletState: (args: { address: string }) => Promise; + handleTonConnectUrl: (args: { url: string }) => Promise; + sendTransaction: (args: { walletAddress: string; toAddress: string; amount: string; comment?: string }) => Promise; + approveConnectRequest: (args: { requestId: any; walletAddress: string }) => Promise; + rejectConnectRequest: (args: { requestId: any; reason?: string }) => Promise; + approveTransactionRequest: (args: { requestId: any }) => Promise; + rejectTransactionRequest: (args: { requestId: any; reason?: string }) => Promise; + approveSignDataRequest: (args: { requestId: any }) => Promise; + rejectSignDataRequest: (args: { requestId: any; reason?: string }) => Promise; + onEvent: (handler: (event: WalletKitBridgeEvent) => void) => () => void; +} + +// Note: Window global augmentations are defined in bridge.ts with more specific types diff --git a/apps/androidkit/package.json b/apps/androidkit/package.json new file mode 100644 index 000000000..abc0501c7 --- /dev/null +++ b/apps/androidkit/package.json @@ -0,0 +1,27 @@ +{ + "name": "androidkit", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:quickjs": "vite build --config vite.config.quickjs.ts", + "build:all": "pnpm run build && pnpm run build:quickjs", + "preview": "vite preview", + "copy:demo": "rm -rf AndroidDemo/app/src/main/assets/walletkit && mkdir -p AndroidDemo/app/src/main/assets/walletkit && cp -R dist-android/* AndroidDemo/app/src/main/assets/walletkit/ && mkdir -p AndroidDemo/app/src/main/assets/walletkit/quickjs && cp dist-android-quickjs/walletkit.quickjs.js AndroidDemo/app/src/main/assets/walletkit/quickjs/index.js" + }, + "dependencies": { + "@ton/core": "^0.61.0", + "@ton/walletkit": "workspace:*", + "@tonconnect/bridge-sdk": "^0.2.4", + "@tonconnect/protocol": "^2.3.0", + "buffer": "^6.0.3", + "whatwg-url": "^13.0.0" + }, + "devDependencies": { + "@types/whatwg-url": "^13.0.0", + "typescript": "^5.4.0", + "vite": "^5.0.0" + } +} \ No newline at end of file diff --git a/apps/androidkit/tsconfig.json b/apps/androidkit/tsconfig.json new file mode 100644 index 000000000..12b75fb4d --- /dev/null +++ b/apps/androidkit/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["js/**/*", "index.html"] +} \ No newline at end of file diff --git a/apps/androidkit/vite.config.quickjs.ts b/apps/androidkit/vite.config.quickjs.ts new file mode 100644 index 000000000..594f1f233 --- /dev/null +++ b/apps/androidkit/vite.config.quickjs.ts @@ -0,0 +1,37 @@ +import path from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + define: { + global: 'globalThis', + 'process.env.NODE_ENV': '"production"', + }, + optimizeDeps: { + esbuildOptions: { + target: 'es2020', + define: { + global: 'globalThis', + 'process.env.NODE_ENV': '"production"', + }, + }, + }, + build: { + target: 'es2020', + sourcemap: false, + minify: false, + emptyOutDir: false, + outDir: 'dist-android-quickjs', + lib: { + entry: path.resolve(__dirname, 'js/src/index.ts'), + name: 'WalletKitQuickJSBundle', + formats: ['iife'], + fileName: () => 'walletkit.quickjs.js', + }, + rollupOptions: { + output: { + inlineDynamicImports: true, + exports: 'none', + }, + }, + }, +}); diff --git a/apps/androidkit/vite.config.ts b/apps/androidkit/vite.config.ts new file mode 100644 index 000000000..eef7454eb --- /dev/null +++ b/apps/androidkit/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', + build: { + outDir: 'dist-android', + emptyOutDir: true, + manifest: true, + minify: false, + rollupOptions: { + input: 'index.html', + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name][extname]', + }, + }, + }, +}); diff --git a/eslint.config.js b/eslint.config.js index c6dc5a3a9..54b0cec17 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,6 +21,7 @@ module.exports = [ '**/dist-extension/*', '**/Packages/TONWalletKit/*', '**/TONWalletApp/TONWalletApp/*', + '**/androidkit/**', ], }, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 079d72adb..e68c14da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1924,6 +1924,9 @@ packages: resolution: {tarball: https://codeload.github.com/the-ton-tech/toolchain/tar.gz/31376da778155bd0984d68abf2a46dce417bacb8} version: 1.5.0 + '@tonconnect/bridge-sdk@0.2.4': + resolution: {integrity: sha512-W7fDoo8aT9FYnGPPLqh4Giiu1M5FP3GW4A+AeDB/xfTzM9XsO9aoWoSfrR1+QuNl2OmvtvneCFv4MnowM9+feg==} + '@tonconnect/bridge-sdk@https://github.com/ton-connect/bridge-sdk/releases/download/v0.2.6/tonconnect-bridge-sdk-0.2.6.tgz': resolution: {tarball: https://github.com/ton-connect/bridge-sdk/releases/download/v0.2.6/tonconnect-bridge-sdk-0.2.6.tgz} version: 0.2.6 @@ -2043,6 +2046,12 @@ packages: '@types/w3c-web-usb@1.0.12': resolution: {integrity: sha512-GD9XFhJZFtCbspsB3t1vD3SgkWVInIMoL1g1CcE0p3DD7abgLrQ2Ws22RS38CXPUCQXgyKjUAGKdy5d0CLT5jw==} + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@13.0.0': + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -5223,6 +5232,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -5690,6 +5703,10 @@ packages: whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-url@13.0.0: + resolution: {integrity: sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==} + engines: {node: '>=16'} + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -8199,6 +8216,14 @@ snapshots: - supports-color - typescript + '@tonconnect/bridge-sdk@0.2.4': + dependencies: + '@tonconnect/isomorphic-eventsource': 0.0.2 + '@tonconnect/isomorphic-fetch': 0.0.3 + '@tonconnect/protocol': 2.3.0 + transitivePeerDependencies: + - encoding + '@tonconnect/bridge-sdk@https://github.com/ton-connect/bridge-sdk/releases/download/v0.2.6/tonconnect-bridge-sdk-0.2.6.tgz': dependencies: '@tonconnect/isomorphic-eventsource': 0.0.2 @@ -8340,6 +8365,12 @@ snapshots: '@types/w3c-web-usb@1.0.12': {} + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@13.0.0': + dependencies: + '@types/webidl-conversions': 7.0.3 + '@types/wrap-ansi@3.0.0': {} '@types/yargs-parser@21.0.3': {} @@ -11956,6 +11987,10 @@ snapshots: tr46@0.0.3: {} + tr46@4.1.1: + dependencies: + punycode: 2.3.1 + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -12408,6 +12443,11 @@ snapshots: whatwg-fetch@3.6.20: {} + whatwg-url@13.0.0: + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + whatwg-url@14.2.0: dependencies: tr46: 5.1.1