android-example-voting

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Kuira Voting dApp

Kuira 投票dApp

This skill generates a runnable voting dApp for Midnight Network using the Kuira Android SDK. Every snippet below is taken directly from the working starter-android codebase. If the build fails, read the Failure Modes sections — they cover the three most common things that go wrong.
Primary references:
  • https://kuiralabs.github.io/kuira-sdk-android/
    — Kuira SDK docs
  • https://kuiralabs.github.io/kuira-sdk-android/api/
    — Dokka API reference
  • https://github.com/kuiralabs/kuira-starter-android
    — official starter
  • https://central.sonatype.com/namespace/io.github.kuiralabs
    — Maven Central

本教程将指导你使用Kuira Android SDK为Midnight Network构建一个可运行的投票dApp。以下所有代码片段均直接取自可运行的starter-android代码库。如果构建失败,请查看故障模式部分——该部分涵盖了最常见的三类问题。
主要参考资料:
  • https://kuiralabs.github.io/kuira-sdk-android/
    — Kuira SDK 官方文档
  • https://kuiralabs.github.io/kuira-sdk-android/api/
    — Dokka API 参考
  • https://github.com/kuiralabs/kuira-starter-android
    — 官方启动模板
  • https://central.sonatype.com/namespace/io.github.kuiralabs
    — Maven Central 仓库

What this builds

构建内容

CapabilityHow
Forge sigil
PanelBar
— one biometric creates passkey-DID + wallet
Deploy contractDeploy the Compact contract to Midnight
Create pollOrganizer-only via SHA-256 admin key match
Cast voteSelect one of 4 options, ZK-proved on-device
Close pollOnly the admin key holder can close
Live results
observeLedger()
streams state changes reactively

功能实现方式
生成Sigil
PanelBar
— 一次生物识别操作即可创建基于密钥的DID + 钱包
部署合约将Compact合约部署到Midnight Network
创建投票仅管理员可操作,通过SHA-256管理员密钥匹配验证
提交投票选择4个选项之一,在设备端完成ZK证明
关闭投票仅管理员密钥持有者可执行关闭操作
实时结果
observeLedger()
流式响应账本状态变化

Pinned Versions

固定版本

These are the actual versions from the working codebase. Deviating causes dependency resolution failures (see §9).
LayerVersion
Kuira SDK (
io.github.kuiralabs:dapp-ui
)
0.1.0-alpha04
Kuira Contract plugin
0.1.0-alpha04
Kuira Localnet plugin
0.1.0-alpha04
AGP
8.13.2
Kotlin
2.3.20
KSP
2.3.6
Hilt
2.58
Compose BOM
2026.03.01
Gradle
8.13
JDK
17
compileSdk
/
targetSdk
36
minSdk
30
appcompat
1.7.0
activity-compose
1.10.1
fragment-ktx
1.8.5
hilt-navigation-compose
1.1.0
security-crypto
1.1.0-alpha07
@midnight-ntwrk/compact-runtime
0.16.0
Compact compiler
0.31.0
(pinned on compile command)
Compact language pragma
>= 0.22

以下是可运行代码库中使用的实际版本。偏离这些版本会导致依赖解析失败(参见第9节)。
层级版本
Kuira SDK (
io.github.kuiralabs:dapp-ui
)
0.1.0-alpha04
Kuira Contract 插件
0.1.0-alpha04
Kuira Localnet 插件
0.1.0-alpha04
AGP
8.13.2
Kotlin
2.3.20
KSP
2.3.6
Hilt
2.58
Compose BOM
2026.03.01
Gradle
8.13
JDK
17
compileSdk
/
targetSdk
36
minSdk
30
appcompat
1.7.0
activity-compose
1.10.1
fragment-ktx
1.8.5
hilt-navigation-compose
1.1.0
security-crypto
1.1.0-alpha07
@midnight-ntwrk/compact-runtime
0.16.0
Compact 编译器
0.31.0
(编译命令中固定版本)
Compact 语言版本声明
>= 0.22

1) Project Structure

1) 项目结构

kuira-starter-android/
├── settings.gradle.kts
├── build.gradle.kts               # Root (plugin declarations only)
├── gradle.properties               # MUST exist — jvmargs, caching
├── gradle/
│   ├── libs.versions.toml
│   └── wrapper/
│       └── gradle-wrapper.properties  # Gradle 8.13
├── local.properties                # sdk.dir — NOT committed
├── contract/
│   ├── package.json
│   ├── scripts/
│   │   └── patch-voting.js
│   └── src/
│       ├── voting.compact
│       └── managed/voting/         # Compiled artifacts (gitignored)
├── app/
│   ├── build.gradle.kts
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── res/
│       │   │   ├── values/
│       │   │   │   ├── strings.xml
│       │   │   │   └── themes.xml
│       │   │   ├── xml/
│       │   │   │   └── network_security_config.xml
│       │   │   ├── drawable/
│       │   │   │   ├── ic_launcher_foreground.xml
│       │   │   │   └── ic_launcher_background.xml
│       │   │   └── mipmap-anydpi-v26/
│       │   │       └── ic_launcher.xml
│       │   └── java/com/kuiralabs/starter/counter/
│       │       ├── KuiraStarterApp.kt
│       │       ├── MainActivity.kt
│       │       ├── di/PasskeyConfigModule.kt
│       │       ├── data/
│       │       │   ├── VotingContract.kt
│       │       │   └── ContractAddressStore.kt
│       │       └── ui/
│       │           ├── theme/{Color,Type,Theme}.kt
│       │           ├── VotingScreen.kt
│       │           ├── VotingCard.kt
│       │           ├── VotingViewModel.kt
│       │           └── VotingUiState.kt
│       └── debug/AndroidManifest.xml   # Cleartext for localnet
Warning:
local.properties
must exist with
sdk.dir=...
or the AGP
android
block can't resolve
compileSdk
. Android Studio creates it; in CI you must create it manually or use an env var.

kuira-starter-android/
├── settings.gradle.kts
├── build.gradle.kts               # 根目录(仅声明插件)
├── gradle.properties               # 必须存在——配置jvm参数、缓存等
├── gradle/
│   ├── libs.versions.toml
│   └── wrapper/
│       └── gradle-wrapper.properties  # Gradle 8.13
├── local.properties                # sdk.dir — 不提交到版本库
├── contract/
│   ├── package.json
│   ├── scripts/
│   │   └── patch-voting.js
│   └── src/
│       ├── voting.compact
│       └── managed/voting/         # 编译产物(已加入git忽略)
├── app/
│   ├── build.gradle.kts
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── res/
│       │   │   ├── values/
│       │   │   │   ├── strings.xml
│       │   │   │   └── themes.xml
│       │   │   ├── xml/
│       │   │   │   └── network_security_config.xml
│       │   │   ├── drawable/
│       │   │   │   ├── ic_launcher_foreground.xml
│       │   │   │   └── ic_launcher_background.xml
│       │   │   └── mipmap-anydpi-v26/
│       │   │       └── ic_launcher.xml
│       │   └── java/com/kuiralabs/starter/counter/
│       │       ├── KuiraStarterApp.kt
│       │       ├── MainActivity.kt
│       │       ├── di/PasskeyConfigModule.kt
│       │       ├── data/
│       │       │   ├── VotingContract.kt
│       │       │   └── ContractAddressStore.kt
│       │       └── ui/
│       │           ├── theme/{Color,Type,Theme}.kt
│       │           ├── VotingScreen.kt
│       │           ├── VotingCard.kt
│       │           ├── VotingViewModel.kt
│       │           └── VotingUiState.kt
│       └── debug/AndroidManifest.xml   # 为本地网络启用明文传输
警告:
local.properties
必须包含
sdk.dir=...
,否则AGP的
android
块无法解析
compileSdk
。Android Studio会自动创建该文件;在CI环境中,你需要手动创建或使用环境变量。

2) Prerequisites

2) 前置条件

2.1) Toolchain

2.1) 工具链

bash
java --version               # JDK 17+
node --version                # Node.js 18+
compact update 0.31.0         # Install Compact compiler
compact list                  # Verify 0.31.0 is the default
bash
java --version               # JDK 17+
node --version                # Node.js 18+
compact update 0.31.0         # 安装Compact编译器
compact list                  # 验证0.31.0是否为默认版本

2.2) Android Setup

2.2) Android 环境配置

  • Android Studio Ladybug+
  • Device/emulator Android 13+ (API 33+) with screen lock set
  • Domain for passkey
    rpId
    (or use GitHub Pages)
  • Google Password Manager (GMS Core 23.40.13+)
  • Android Studio Ladybug及以上版本
  • Android 13+(API 33+)的设备/模拟器,且已设置屏幕锁
  • 用于Passkey
    rpId
    的域名(或使用GitHub Pages)
  • Google密码管理器(GMS Core 23.40.13+)

2.3) Gradle wrapper

2.3) Gradle 包装器

The wrapper at
gradle/wrapper/gradle-wrapper.properties
must specify Gradle 8.13 (matching AGP 8.13.2):
properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

gradle/wrapper/gradle-wrapper.properties
中的包装器必须指定Gradle 8.13(与AGP 8.13.2匹配):
properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

3) Gradle Configuration

3) Gradle 配置

3.1)
gradle.properties
REQUIRED

3.1)
gradle.properties
必填

Without this file, Gradle uses defaults that make the build extremely slow or break the configuration cache:
properties
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
Failure mode: Without
android.useAndroidX=true
, the SDK's
AppCompatActivity
dependency resolves to the old support library and fails at runtime with
ClassNotFoundException
.
Failure mode: Without
org.gradle.jvmargs=-Xmx2048m
, KSP+Hilt together can OOM on large-ish projects. Bump to
-Xmx4096m
if you see
OutOfMemoryError: Metaspace
.
Failure mode:
org.gradle.configuration-cache=true
will break if your
app/build.gradle.kts
accesses project files at configuration time (e.g.,
file("../contract")
). The Copy tasks below avoid this by using
rootProject.file()
with
doFirst
, but if you add a plugin that reads files during evaluation, disable configuration cache.
如果没有此文件,Gradle会使用默认配置,导致构建极慢或配置缓存失效:
properties
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
故障模式: 若缺少
android.useAndroidX=true
,SDK的
AppCompatActivity
依赖会解析为旧版支持库,运行时会抛出
ClassNotFoundException
故障模式: 若缺少
org.gradle.jvmargs=-Xmx2048m
,KSP+Hilt组合在较大项目中可能会出现内存溢出。如果遇到
OutOfMemoryError: Metaspace
,可将值调整为
-Xmx4096m
故障模式:
org.gradle.configuration-cache=true
会在
app/build.gradle.kts
在配置阶段访问项目文件(如
file("../contract")
)时失效。以下的Copy任务通过使用
rootProject.file()
配合
doFirst
避免了此问题,但如果添加了在评估阶段读取文件的插件,请禁用配置缓存。

3.2)
settings.gradle.kts

3.2)
settings.gradle.kts

kotlin
pluginManagement {
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "kuira-starter-android"
include(":app")
Gotcha:
FAIL_ON_PROJECT_REPOS
means you CANNOT declare
repositories { ... }
inside
app/build.gradle.kts
. If a library isn't in google() or mavenCentral(), add it to
dependencyResolutionManagement
here.
kotlin
pluginManagement {
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "kuira-starter-android"
include(":app")
注意:
FAIL_ON_PROJECT_REPOS
意味着你不能
app/build.gradle.kts
中声明
repositories { ... }
。如果某个库不在google()或mavenCentral()中,请将其添加到此处的
dependencyResolutionManagement
中。

3.3) Root
build.gradle.kts

3.3) 根目录
build.gradle.kts

kotlin
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
}
Note: The Kuira contract plugin (
io.github.kuiralabs.contract
) and localnet plugin (
io.github.kuiralabs.localnet
) are applied by ID and version directly in
app/build.gradle.kts
, not through the version catalog. They do NOT appear here.
kotlin
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
}
注意:Kuira合约插件(
io.github.kuiralabs.contract
)和localnet插件(
io.github.kuiralabs.localnet
)通过ID和版本直接在
app/build.gradle.kts
中应用,而非通过版本目录。它们不会出现在此文件中。

3.4)
gradle/libs.versions.toml

3.4)
gradle/libs.versions.toml

toml
[versions]
agp = "8.13.2"
kotlin = "2.3.20"
ksp = "2.3.6"
hilt = "2.58"
composeBom = "2026.03.01"

androidxAppcompat = "1.7.0"
androidxActivity = "1.10.1"
androidxFragment = "1.8.5"
androidxLifecycle = "2.8.7"
androidxHiltNavCompose = "1.1.0"
androidxSecurityCrypto = "1.1.0-alpha07"

kuira = "0.1.0-alpha04"

[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" }

androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }

hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }

kuira-dapp-ui = { group = "io.github.kuiralabs", name = "dapp-ui", version.ref = "kuira" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
Version pinning rules:
  • AGP and Gradle wrapper MUST match major.minor: AGP 8.13.x ↔ Gradle 8.13
  • Kotlin and KSP MUST match exactly: Kotlin 2.3.20 ↔ KSP 2.3.6
  • Compose BOM and Compose compiler plugin are coupled via
    kotlin-compose
    plugin — do NOT set a compose-compiler version manually
  • Hilt and ksp-hilt must match the Hilt version (the ksp artifact resolves transitively, no explicit dep needed)
toml
[versions]
agp = "8.13.2"
kotlin = "2.3.20"
ksp = "2.3.6"
hilt = "2.58"
composeBom = "2026.03.01"

androidxAppcompat = "1.7.0"
androidxActivity = "1.10.1"
androidxFragment = "1.8.5"
androidxLifecycle = "2.8.7"
androidxHiltNavCompose = "1.1.0"
androidxSecurityCrypto = "1.1.0-alpha07"

kuira = "0.1.0-alpha04"

[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" }

androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose", name = "material3" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }

hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }

kuira-dapp-ui = { group = "io.github.kuiralabs", name = "dapp-ui", version.ref = "kuira" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
版本固定规则:
  • AGP和Gradle包装器的主版本和次版本必须匹配:AGP 8.13.x ↔ Gradle 8.13
  • Kotlin和KSP版本必须完全匹配:Kotlin 2.3.20 ↔ KSP 2.3.6
  • Compose BOM和Compose编译器插件通过
    kotlin-compose
    插件关联——不要手动设置compose-compiler版本
  • Hilt和ksp-hilt版本必须匹配Hilt版本(ksp依赖会自动解析,无需显式声明)

3.5)
app/build.gradle.kts

3.5)
app/build.gradle.kts

kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
    id("io.github.kuiralabs.contract") version "0.1.0-alpha04"
    id("io.github.kuiralabs.localnet") version "0.1.0-alpha04"
}

android {
    namespace = "com.kuiralabs.starter.counter"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.kuiralabs.starter.counter"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "0.1.0"
    }

    buildTypes {
        debug { isMinifyEnabled = false }
        release { isMinifyEnabled = false }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    buildFeatures { compose = true }
}

kotlin {
    compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}

kuiraContract {
    source.set("../contract/src/managed/voting")
    bundleWalletKeys.set(true)
}

// Hand-rolled Copy to sync voting contract assets (the plugin handles
// the primary source; this is explicit for clarity and matches the
// starter codebase pattern):
tasks.register<Copy>("syncVotingAssets") {
    description = "Sync compiled Voting compact contract artifacts into app assets."
    group = "build"
    from("../contract/src/managed/voting/contract") {
        include("index.js")
        rename { "voting-contract.js" }
        into("runtime")
    }
    from("../contract/src/managed/voting/keys") {
        include("*.prover", "*.verifier")
        into("keys")
    }
    from("../contract/src/managed/voting/zkir") {
        include("*.bzkir")
        into("keys")
    }
    into("src/main/assets")
    doFirst {
        if (!file("../contract/src/managed/voting").exists()) {
            throw GradleException("Contract not compiled — run `npm run compile:voting` in contract/ first.")
        }
    }
}

tasks.named("preBuild") { dependsOn("syncVotingAssets") }

dependencies {
    implementation(libs.kuira.dapp.ui)

    val composeBom = platform(libs.androidx.compose.bom)
    implementation(composeBom)
    androidTestImplementation(composeBom)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)

    implementation(libs.androidx.appcompat)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.fragment.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.hilt.navigation.compose)
    implementation(libs.androidx.security.crypto)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
}
Plugin details:
io.github.kuiralabs.contract
— processes the
kuiraContract { ... }
block. It:
  1. Registers
    validateKuiraContractSource
    — fails the build with a clear message if
    ../contract/src/managed/voting
    doesn't exist
  2. Registers
    syncContractAssets
    — copies JS + keys into
    src/main/assets/{runtime,keys}/
  3. Wires
    syncContractAssets
    into
    preBuild
  4. The
    bundleWalletKeys.set(true)
    flag makes the plugin copy
    .bzkir
    bundle files into assets during the bundle task
io.github.kuiralabs.localnet
— auto-runs
adb reverse
for the localnet ports on physical devices during
installDebug
. No-op on emulators (they use
10.0.2.2
).
Why both plugin and Copy task? The
kuiraContract
plugin handles the primary source automatically (syncing JS + keys + bundleWalletKeys). The hand-rolled
Copy
task exists in the starter codebase as an explicit parallel sync. For this skill, the plugin alone is sufficient; the Copy task is provided for clarity.
Failure mode: If
minSdk < 30
, the SDK fails at runtime because it depends on
android.security.identity.IdentityCredential
(API 30+) and
CredentialManager
(API 34+, with API 30+ fallback path).
Failure mode: If
compileSdk != 36
or
targetSdk != 36
, the Kuira SDK's
WalletNotifications.PERMISSION
constant (
POST_NOTIFICATIONS
added API 33, behavior change in API 34) may not resolve correctly. Keep compileSdk = 36.

kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
    id("io.github.kuiralabs.contract") version "0.1.0-alpha04"
    id("io.github.kuiralabs.localnet") version "0.1.0-alpha04"
}

android {
    namespace = "com.kuiralabs.starter.counter"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.kuiralabs.starter.counter"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "0.1.0"
    }

    buildTypes {
        debug { isMinifyEnabled = false }
        release { isMinifyEnabled = false }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    buildFeatures { compose = true }
}

kotlin {
    compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}

kuiraContract {
    source.set("../contract/src/managed/voting")
    bundleWalletKeys.set(true)
}

// 手动编写Copy任务同步投票合约资源(插件会处理主要资源;此任务为了清晰展示,与启动模板代码模式一致):
tasks.register<Copy>("syncVotingAssets") {
    description = "将编译后的Voting compact合约产物同步到app资源目录。"
    group = "build"
    from("../contract/src/managed/voting/contract") {
        include("index.js")
        rename { "voting-contract.js" }
        into("runtime")
    }
    from("../contract/src/managed/voting/keys") {
        include("*.prover", "*.verifier")
        into("keys")
    }
    from("../contract/src/managed/voting/zkir") {
        include("*.bzkir")
        into("keys")
    }
    into("src/main/assets")
    doFirst {
        if (!file("../contract/src/managed/voting").exists()) {
            throw GradleException("合约未编译——请先在contract/目录下运行`npm run compile:voting`。")
        }
    }
}

tasks.named("preBuild") { dependsOn("syncVotingAssets") }

dependencies {
    implementation(libs.kuira.dapp.ui)

    val composeBom = platform(libs.androidx.compose.bom)
    implementation(composeBom)
    androidTestImplementation(composeBom)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)

    implementation(libs.androidx.appcompat)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.fragment.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.hilt.navigation.compose)
    implementation(libs.androidx.security.crypto)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
}
插件详情:
io.github.kuiralabs.contract
— 处理
kuiraContract { ... }
块,功能包括:
  1. 注册
    validateKuiraContractSource
    — 如果
    ../contract/src/managed/voting
    不存在,会清晰提示并终止构建
  2. 注册
    syncContractAssets
    — 将JS文件和密钥复制到
    src/main/assets/{runtime,keys}/
  3. syncContractAssets
    关联到
    preBuild
    任务
  4. bundleWalletKeys.set(true)
    标志会让插件在bundle任务中将
    .bzkir
    包文件复制到资源目录
io.github.kuiralabs.localnet
— 在执行
installDebug
时,自动为物理设备运行
adb reverse
以映射本地网络端口。在模拟器上无操作(模拟器使用
10.0.2.2
)。
为什么同时使用插件和Copy任务?
kuiraContract
插件会自动处理主要资源同步(JS、密钥、bundleWalletKeys)。手动编写的Copy任务在启动模板中作为显式的同步示例存在。对于本教程,仅使用插件即可;提供Copy任务是为了更清晰地展示流程。
故障模式: 如果
minSdk < 30
,SDK运行时会失败,因为它依赖于
android.security.identity.IdentityCredential
(API 30+)和
CredentialManager
(API 34+,API 30+有兼容路径)。
故障模式: 如果
compileSdk != 36
targetSdk != 36
,Kuira SDK的
WalletNotifications.PERMISSION
常量(
POST_NOTIFICATIONS
在API 33添加,API 34有行为变更)可能无法正确解析。请保持compileSdk = 36。

4) Android Resources

4) Android 资源

4.1)
app/src/main/res/values/strings.xml

4.1)
app/src/main/res/values/strings.xml

xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Kuira Starter</string>
</resources>
xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Kuira Starter</string>
</resources>

4.2)
app/src/main/res/values/themes.xml

4.2)
app/src/main/res/values/themes.xml

xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.KuiraStarter" parent="Theme.AppCompat.DayNight.NoActionBar">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
        <item name="android:windowBackground">@android:color/black</item>
    </style>
</resources>
Why AppCompat parent?
MainActivity
extends
AppCompatActivity
(not
ComponentActivity
) because the SDK's
SigilStatusPanel
hosts a biometric prompt internally. The prompt requires a
FragmentActivity
AppCompatActivity
satisfies that;
ComponentActivity
does not. The theme XML must have a compatible parent.
xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.KuiraStarter" parent="Theme.AppCompat.DayNight.NoActionBar">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
        <item name="android:windowBackground">@android:color/black</item>
    </style>
</resources>
为什么使用AppCompat父主题?
MainActivity
继承自
AppCompatActivity
(而非
ComponentActivity
),因为SDK的
SigilStatusPanel
内部包含生物识别提示框。该提示框需要
FragmentActivity
——
AppCompatActivity
满足此要求,而
ComponentActivity
不满足。主题XML必须使用兼容的父主题。

4.3)
app/src/main/res/xml/network_security_config.xml

4.3)
app/src/main/res/xml/network_security_config.xml

xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">127.0.0.1</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
    <base-config cleartextTrafficPermitted="false" />
</network-security-config>
Prefer this over
android:usesCleartextTraffic="true"
in the manifest. Both work on localnet, but this approach doesn't open cleartext to the whole internet for the debug build. If you use the debug manifest approach (see §4.5), it's enabled only for the
debug
build type, so it's also safe.
xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">127.0.0.1</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
    <base-config cleartextTrafficPermitted="false" />
</network-security-config>
推荐使用此方式,而非在清单中设置
android:usesCleartextTraffic="true"
。两种方式在本地网络中都能工作,但此方法不会为debug构建开放全局明文传输。如果使用debug清单方式(参见第4.5节),仅会在
debug
构建类型中启用,因此也是安全的。

4.4) Main
AndroidManifest.xml

4.4) 主
AndroidManifest.xml

xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".KuiraStarterApp"
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:supportsRtl="true"
        android:theme="@style/Theme.KuiraStarter">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.KuiraStarter">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
Network config: The
android:networkSecurityConfig
attribute pointing to
@xml/network_security_config
allows cleartext to localhost/emulator host for localnet without globally enabling it.
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".KuiraStarterApp"
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:supportsRtl="true"
        android:theme="@style/Theme.KuiraStarter">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.KuiraStarter">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
网络配置:
android:networkSecurityConfig
属性指向
@xml/network_security_config
,允许本地网络/模拟器主机使用明文传输,而无需全局启用。

4.5) Debug manifest (alternative to network_security_config)

4.5) Debug清单(network_security_config的替代方案)

app/src/debug/AndroidManifest.xml
:
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:usesCleartextTraffic="true" />
</manifest>
Merge rules: the debug manifest is merged into the main manifest for debug builds only. It's simpler than the security config approach but opens cleartext to ALL hosts, not just localnet.
Failure mode: If you use both
networkSecurityConfig
AND
usesCleartextTraffic="true"
, the security config wins. If the config doesn't include the right domain, cleartext still fails.

app/src/debug/AndroidManifest.xml
:
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:usesCleartextTraffic="true" />
</manifest>
合并规则:debug清单仅在debug构建时合并到主清单中。此方法比安全配置方法更简单,但会向所有主机开放明文传输,而非仅本地网络。
故障模式: 如果同时使用
networkSecurityConfig
usesCleartextTraffic="true"
,安全配置会优先生效。如果配置中未包含正确的域名,明文传输仍会失败。

5) Contract

5) 合约

5.1)
contract/package.json

5.1)
contract/package.json

json
{
  "name": "@yourapp/voting-contract",
  "version": "0.1.0",
  "private": true,
  "description": "Voting contract for the Kuira Voting dApp. Compiled artifacts under src/managed/voting/ are consumed by the Android app via the kuiraContract Gradle plugin.",
  "scripts": {
    "compile:voting": "compact compile +0.31.0 src/voting.compact src/managed/voting && node scripts/patch-voting.js",
    "inspect:voting": "mn contract inspect --managed src/managed/voting"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.16.0"
  },
  "engines": {
    "compactc": "0.31.0"
  }
}
Failure mode: Runtime version mismatch. The compiled JS at
src/managed/voting/contract/index.js
has a hard-coded runtime version require. If the
@midnight-ntwrk/compact-runtime
version in
package.json
doesn't match what the compiler baked in at compile time, the Android app throws a JS runtime error when loading the contract.
Symptoms:
"Could not find module '@midnight-ntwrk/compact-runtime'"
or a cryptic
TypeError
from the JS engine.
Fix: Check
contract-info.json
in the managed output:
bash
cat src/managed/voting/compiler/contract-info.json
json
{
  "name": "@yourapp/voting-contract",
  "version": "0.1.0",
  "private": true,
  "description": "Kuira投票dApp的投票合约。src/managed/voting/下的编译产物通过kuiraContract Gradle插件供Android应用使用。",
  "scripts": {
    "compile:voting": "compact compile +0.31.0 src/voting.compact src/managed/voting && node scripts/patch-voting.js",
    "inspect:voting": "mn contract inspect --managed src/managed/voting"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.16.0"
  },
  "engines": {
    "compactc": "0.31.0"
  }
}
故障模式:运行时版本不匹配
src/managed/voting/contract/index.js
中的编译后JS硬编码了运行时版本依赖。如果
package.json
中的
@midnight-ntwrk/compact-runtime
版本与编译时编译器内置的版本不匹配,Android应用加载合约时会抛出JS运行时错误。
症状:
"Could not find module '@midnight-ntwrk/compact-runtime'"
或来自JS引擎的模糊
TypeError
修复: 检查输出目录中的
contract-info.json
bash
cat src/managed/voting/compiler/contract-info.json

Look for "runtime-version" — must match package.json's dep

查找"runtime-version" — 必须与package.json中的依赖版本匹配

If they differ, either bump `@midnight-ntwrk/compact-runtime` in
`package.json` to match, or recompile with the correct toolchain
version.

**The `+0.31.0` pin** on the `compact compile` command is critical.
Without it, the command uses whatever compiler `compact` toolchain
has as default, which may mismatch the runtime version the contract
was last compiled with. Always pin.
如果版本不同,要么将`package.json`中的`@midnight-ntwrk/compact-runtime`版本升级到匹配版本,要么使用正确的工具链版本重新编译。

**`compact compile`命令中的`+0.31.0`固定版本至关重要**。如果没有此固定版本,命令会使用`compact`工具链中的默认编译器版本,可能与合约上次编译时的运行时版本不匹配。请始终固定版本。

5.2)
contract/scripts/patch-voting.js

5.2)
contract/scripts/patch-voting.js

javascript
const fs = require('fs');
const path = require('path');

const file = path.join(__dirname, '..', 'src', 'managed', 'voting', 'contract', 'index.js');
let src = fs.readFileSync(file, 'utf8');

src = src.replace(
  "const optionIdx_0 = args_1[1]",
  "let optionIdx_0 = args_1[1];\n        if (typeof optionIdx_0 !== 'bigint') optionIdx_0 = BigInt(optionIdx_0)"
);

fs.writeFileSync(file, src, 'utf8');
console.log('Patched BigInt shim into voting contract JS');
Why this patch is needed: The Kuira SDK's Kotlin-to-JS bridge sends Kotlin
Long
as JS
number
, not
bigint
. The Compact type
Uint<64>
expects
bigint
. Without the patch,
castVote(0)
passes
0
(JS number) which fails
typeof
checks inside the runtime.
Failure mode: If
compact compile
generates different variable names, this string match silently no-ops. After any toolchain bump, diff
contract/index.js
against a known-good copy.
Failure mode: The patched line assumes
optionIdx_0 = args_1[1]
is the first argument to the castVote circuit. This is compiler- generated and can change. Always verify the patch works after recompiling.
javascript
const fs = require('fs');
const path = require('path');

const file = path.join(__dirname, '..', 'src', 'managed', 'voting', 'contract', 'index.js');
let src = fs.readFileSync(file, 'utf8');

src = src.replace(
  "const optionIdx_0 = args_1[1]",
  "let optionIdx_0 = args_1[1];\n        if (typeof optionIdx_0 !== 'bigint') optionIdx_0 = BigInt(optionIdx_0)"
);

fs.writeFileSync(file, src, 'utf8');
console.log('Patched BigInt shim into voting contract JS');
为什么需要此补丁? Kuira SDK的Kotlin-to-JS桥接将Kotlin
Long
作为JS
number
发送,而非
bigint
。Compact类型
Uint<64>
期望接收
bigint
。如果没有此补丁,
castVote(0)
会传递
0
(JS number),这会在运行时内部的
typeof
检查中失败。
故障模式: 如果
compact compile
生成了不同的变量名,此字符串匹配会静默失效。任何工具链升级后,请对比
contract/index.js
与已知正确的版本。
故障模式: 补丁行假设
optionIdx_0 = args_1[1]
是castVote电路的第一个参数。这是编译器生成的,可能会变化。重新编译后请始终验证补丁是否有效。

5.3)
contract/src/voting.compact

5.3)
contract/src/voting.compact

compact
pragma language_version >= 0.22;

import CompactStandardLibrary;

export ledger pollQuestion: Opaque<"string">;
export ledger pollOptions: Opaque<"string">;
export ledger adminKeyHash: Bytes<32>;
export ledger voteCount: Uint<64>;
export ledger tally0: Uint<64>;
export ledger tally1: Uint<64>;
export ledger tally2: Uint<64>;
export ledger tally3: Uint<64>;
export ledger pollClosed: Boolean;

witness adminSecret(): Bytes<32>;

pure circuit deriveAdminKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "poll:admin:v1"), sk]);
}

constructor() {
  pollClosed = false;
  tally0 = 0;
  tally1 = 0;
  tally2 = 0;
  tally3 = 0;
  voteCount = 0;
}

export circuit createPoll(question: Opaque<"string">, options: Opaque<"string">): [] {
  pollQuestion = disclose(question);
  pollOptions = disclose(options);
  adminKeyHash = disclose(deriveAdminKey(adminSecret()));
}

export circuit castVote(optionIdx: Uint<64>): [] {
  assert(!pollClosed, "Poll is closed");
  assert(optionIdx < 4, "Invalid option index (0-3)");

  const idx = disclose(optionIdx);
  const count = voteCount;
  voteCount = disclose((count + 1) as Uint<64>);

  if (idx == 0) {
    tally0 = (tally0 + 1) as Uint<64>;
  } else if (idx == 1) {
    tally1 = (tally1 + 1) as Uint<64>;
  } else if (idx == 2) {
    tally2 = (tally2 + 1) as Uint<64>;
  } else {
    tally3 = (tally3 + 1) as Uint<64>;
  }
}

export circuit closePoll(): [] {
  assert(adminKeyHash == deriveAdminKey(adminSecret()), "Only admin can close poll");
  pollClosed = disclose(true);
}
Key contract design decisions:
  • No
    created
    ledger field.
    Unlike typical CRUD contracts, this contract doesn't track whether a poll has been created. The UI infers this from
    pollQuestion != ""
    . Calling
    createPoll
    twice would overwrite the first poll (there's no
    assert(!created)
    guard). This is intentional for simplicity.
  • deriveAdminKey
    is a
    pure circuit
    .
    This means it produces no public output on its own — it's only used inside other circuits. The
    pure
    keyword means the circuit has no ledger writes and no public inputs; it's a helper function.
  • >= 0.22
    language version range
    (not exact). This accepts any compatible 0.22.x compiler. The
    compact compile +0.31.0
    command line pin is what actually selects the compiler.
  • pollClosed
    is set to
    true
    via
    disclose()
    .
    When the poll is closed,
    pollClosed
    becomes public and visible on-chain. Before closing, it defaults to
    false
    (from the constructor).
  • No double-vote guard. There's no
    Set<Bytes<32>>
    checking voter identity. The
    hasVoted
    flag in the UI is just a local cache from
    observeLedger()
    . A user who votes, reinstalls, and votes again would succeed. This is by design for this simple contract.
  • adminKeyHash
    is set in
    createPoll
    , not in the constructor.
    The constructor initializes it to... nothing (the Compact default). This means the first
    createPoll
    sets the admin key; subsequent calls would overwrite it (there's no guard). The Android side's
    closePoll
    assert checks against the CURRENT
    adminKeyHash
    , so only someone who knows
    adminSecret()
    can close.
compact
pragma language_version >= 0.22;

import CompactStandardLibrary;

export ledger pollQuestion: Opaque<"string">;
export ledger pollOptions: Opaque<"string">;
export ledger adminKeyHash: Bytes<32>;
export ledger voteCount: Uint<64>;
export ledger tally0: Uint<64>;
export ledger tally1: Uint<64>;
export ledger tally2: Uint<64>;
export ledger tally3: Uint<64>;
export ledger pollClosed: Boolean;

witness adminSecret(): Bytes<32>;

pure circuit deriveAdminKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "poll:admin:v1"), sk]);
}

constructor() {
  pollClosed = false;
  tally0 = 0;
  tally1 = 0;
  tally2 = 0;
  tally3 = 0;
  voteCount = 0;
}

export circuit createPoll(question: Opaque<"string">, options: Opaque<"string">): [] {
  pollQuestion = disclose(question);
  pollOptions = disclose(options);
  adminKeyHash = disclose(deriveAdminKey(adminSecret()));
}

export circuit castVote(optionIdx: Uint<64>): [] {
  assert(!pollClosed, "Poll is closed");
  assert(optionIdx < 4, "Invalid option index (0-3)");

  const idx = disclose(optionIdx);
  const count = voteCount;
  voteCount = disclose((count + 1) as Uint<64>);

  if (idx == 0) {
    tally0 = (tally0 + 1) as Uint<64>;
  } else if (idx == 1) {
    tally1 = (tally1 + 1) as Uint<64>;
  } else if (idx == 2) {
    tally2 = (tally2 + 1) as Uint<64>;
  } else {
    tally3 = (tally3 + 1) as Uint<64>;
  }
}

export circuit closePoll(): [] {
  assert(adminKeyHash == deriveAdminKey(adminSecret()), "Only admin can close poll");
  pollClosed = disclose(true);
}
合约设计关键决策:
  • created
    账本字段
    。与典型的CRUD合约不同,本合约不跟踪投票是否已创建。UI通过
    pollQuestion != ""
    推断投票状态。多次调用
    createPoll
    会覆盖之前的投票(没有
    assert(!created)
    守卫)。这是为了简化设计而故意为之。
  • deriveAdminKey
    pure circuit
    。这意味着它自身不会产生任何公开输出——仅在其他电路内部使用。
    pure
    关键字表示该电路没有账本写入操作,也没有公开输入;它是一个辅助函数。
  • >= 0.22
    语言版本范围
    (非精确版本)。这会接受任何兼容的0.22.x编译器。
    compact compile +0.31.0
    命令行固定版本才是实际选择编译器的依据。
  • pollClosed
    通过
    disclose()
    设置为
    true
    。投票关闭后,
    pollClosed
    变为公开状态并在链上可见。关闭前,它从构造函数默认值
    false
    开始。
  • 无重复投票守卫。没有
    Set<Bytes<32>>
    检查投票者身份。UI中的
    hasVoted
    标志只是来自
    observeLedger()
    的本地缓存。用户投票后重新安装应用并再次投票会成功。这是本简单合约的设计意图。
  • adminKeyHash
    createPoll
    中设置,而非构造函数
    。构造函数未初始化该字段(使用Compact默认值)。这意味着第一次
    createPoll
    会设置管理员密钥;后续调用会覆盖它(没有守卫)。Android端的
    closePoll
    断言会检查当前的
    adminKeyHash
    ,因此只有知道
    adminSecret()
    的人才能关闭投票。

5.4) Compile the contract

5.4) 编译合约

bash
cd contract
npm install
npm run compile:voting
Expected output:
Compiling 3 circuits:
  circuit "createPoll" (k=...)
  circuit "castVote" (k=...)
  circuit "closePoll" (k=...)
Patched BigInt shim into voting contract JS
If the patch line doesn't appear, the
contract/index.js
doesn't have the expected variable name. Check and update the regex.
After compiling, verify the runtime version:
bash
cat src/managed/voting/compiler/contract-info.json
bash
cd contract
npm install
npm run compile:voting
预期输出:
Compiling 3 circuits:
  circuit "createPoll" (k=...)
  circuit "castVote" (k=...)
  circuit "closePoll" (k=...)
Patched BigInt shim into voting contract JS
如果未出现补丁行,说明
contract/index.js
没有预期的变量名。请检查并更新正则表达式。
编译完成后,验证运行时版本:
bash
cat src/managed/voting/compiler/contract-info.json

Confirm runtime-version matches @midnight-ntwrk/compact-runtime in package.json

确认runtime-version与package.json中的@midnight-ntwrk/compact-runtime版本匹配


---

---

6) Application & DI

6) 应用与依赖注入

6.1)
KuiraStarterApp.kt

6.1)
KuiraStarterApp.kt

kotlin
package com.kuiralabs.starter.counter

import android.app.Application
import com.midnight.kuira.sdk.walletruntime.SessionLock
import com.midnight.kuira.sdk.walletruntime.WalletForegroundService
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class KuiraStarterApp : Application() {
    override fun onCreate() {
        super.onCreate()
        SessionLock.attach(this)
        WalletForegroundService.attach(this)
    }
}
SessionLock.attach(this)
registers the app-level session lock triggers: background detection (screen off / home button) and idle timeout. Per-Activity reset is handled by
onUserInteraction
.
WalletForegroundService.attach(this)
keeps wallet operations (dust sync, send, contract calls) alive when the app backgrounds. Shows a progress notification; tears down on foreground or lock.
Failure mode: If you forget
@HiltAndroidApp
, Dagger can't initialize the dependency graph and you get a runtime crash on launch (
dagger.hilt.internal.NoopComponentManager
).
kotlin
package com.kuiralabs.starter.counter

import android.app.Application
import com.midnight.kuira.sdk.walletruntime.SessionLock
import com.midnight.kuira.sdk.walletruntime.WalletForegroundService
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class KuiraStarterApp : Application() {
    override fun onCreate() {
        super.onCreate()
        SessionLock.attach(this)
        WalletForegroundService.attach(this)
    }
}
SessionLock.attach(this)
注册应用级会话锁触发器:后台检测(屏幕关闭/主页按钮)和空闲超时。每个Activity的重置由
onUserInteraction
处理。
WalletForegroundService.attach(this)
确保钱包操作(dust同步、发送、合约调用)在应用后台时仍能继续运行。会显示进度通知;应用回到前台或锁定时停止。
故障模式: 如果忘记添加
@HiltAndroidApp
,Dagger无法初始化依赖图,启动时会崩溃(
dagger.hilt.internal.NoopComponentManager
)。

6.2)
di/PasskeyConfigModule.kt

6.2)
di/PasskeyConfigModule.kt

kotlin
package com.kuiralabs.starter.counter.di

import com.midnight.kuira.core.identity.passkey.PasskeyConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object PasskeyConfigModule {

    private const val PASSKEY_RP_ID = "YOUR_DOMAIN.example.com"

    @Provides
    @Singleton
    fun providePasskeyConfig(): PasskeyConfig =
        PasskeyConfig(rpId = PASSKEY_RP_ID, rpName = "Kuira Sigil")
}
Why Dagger won't compile without this: The Kuira SDK declares
PasskeyConfig
as a required binding in its own module. If the consuming app doesn't provide one, Dagger fails at compile time with
[Dagger/MissingBinding] PasskeyConfig is injected at ...
. This is intentional — the SDK can't know your domain.
Passkey domain setup:
  1. Set
    PASSKEY_RP_ID
    to a domain you control
  2. Host
    assetlinks.json
    at
    https://<rpId>/.well-known/assetlinks.json
    with your app's
    applicationId
    and signing SHA-256:
    bash
    ./gradlew signingReport | grep SHA-256
    json
    [{
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.kuiralabs.starter.counter",
        "sha256_cert_fingerprints": ["SHA-256:..."]
      }
    }]
Failure mode:
RP_ID_MISMATCH
at runtime: the passkey credential was created with one
rpId
but you're now using a different one. Only solution: delete the credential from Google Password Manager and re-create it.

kotlin
package com.kuiralabs.starter.counter.di

import com.midnight.kuira.core.identity.passkey.PasskeyConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object PasskeyConfigModule {

    private const val PASSKEY_RP_ID = "YOUR_DOMAIN.example.com"

    @Provides
    @Singleton
    fun providePasskeyConfig(): PasskeyConfig =
        PasskeyConfig(rpId = PASSKEY_RP_ID, rpName = "Kuira Sigil")
}
为什么没有此模块Dagger无法编译? Kuira SDK在其自身模块中声明
PasskeyConfig
为必需绑定。如果消费应用未提供该绑定,Dagger会在编译时抛出
[Dagger/MissingBinding] PasskeyConfig is injected at ...
错误。这是故意设计的——SDK无法知晓你的域名。
Passkey域名设置:
  1. PASSKEY_RP_ID
    设置为你控制的域名
  2. https://<rpId>/.well-known/assetlinks.json
    托管
    assetlinks.json
    ,包含应用的
    applicationId
    和签名SHA-256:
    bash
    ./gradlew signingReport | grep SHA-256
    json
    [{
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.kuiralabs.starter.counter",
        "sha256_cert_fingerprints": ["SHA-256:..."]
      }
    }]
故障模式: 运行时出现
RP_ID_MISMATCH
:Passkey凭据是使用一个
rpId
创建的,但现在使用了不同的
rpId
。唯一解决方案是从Google密码管理器中删除凭据并重新创建。

7) Data Layer

7) 数据层

7.1)
data/VotingContract.kt

7.1)
data/VotingContract.kt

This is the core contract wrapper. Every detail matters.
kotlin
package com.kuiralabs.starter.counter.data

import android.content.Context
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.compact.WitnessResult
import com.midnight.kuira.core.compact.proving.ProvingKeyManager
import com.midnight.kuira.sdk.MidnightSdk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.MessageDigest

internal object VotingContract {

    private const val NAME = "voting"
    private const val CIRCUIT_CREATE_POLL = "createPoll"
    private const val CIRCUIT_CAST_VOTE = "castVote"
    private const val CIRCUIT_CLOSE_POLL = "closePoll"
    private const val WITNESS_ADMIN_SECRET = "adminSecret"

    private const val LEDGER_FIELD_POLL_QUESTION = "pollQuestion"
    private const val LEDGER_FIELD_POLL_OPTIONS = "pollOptions"
    private const val LEDGER_FIELD_ADMIN_KEY_HASH = "adminKeyHash"
    private const val LEDGER_FIELD_VOTE_COUNT = "voteCount"
    private const val LEDGER_FIELD_TALLY0 = "tally0"
    private const val LEDGER_FIELD_TALLY1 = "tally1"
    private const val LEDGER_FIELD_TALLY2 = "tally2"
    private const val LEDGER_FIELD_TALLY3 = "tally3"
    private const val LEDGER_FIELD_POLL_CLOSED = "pollClosed"

    private const val CONTRACT_JS_ASSET = "runtime/$NAME-contract.js"
    private const val VERIFIER_ASSET_CREATE = "keys/$CIRCUIT_CREATE_POLL.verifier"
    private const val VERIFIER_ASSET_CAST = "keys/$CIRCUIT_CAST_VOTE.verifier"
    private const val VERIFIER_ASSET_CLOSE = "keys/$CIRCUIT_CLOSE_POLL.verifier"

    private val ADMIN_SECRET = ByteArray(32) { 0x42 }

    private fun loadVerifierKeys(context: Context): Map<String, ByteArray> {
        val createVerifier = context.assets.open(VERIFIER_ASSET_CREATE).use { it.readBytes() }
        val castVerifier = context.assets.open(VERIFIER_ASSET_CAST).use { it.readBytes() }
        val closeVerifier = context.assets.open(VERIFIER_ASSET_CLOSE).use { it.readBytes() }
        return mapOf(
            CIRCUIT_CREATE_POLL to createVerifier,
            CIRCUIT_CAST_VOTE to castVerifier,
            CIRCUIT_CLOSE_POLL to closeVerifier,
        )
    }

    private fun installProvingKeys(context: Context) {
        ProvingKeyManager(context).installCircuitKeysFromAssets()
    }

    private fun buildHandle(
        context: Context,
        sdk: MidnightSdk,
        address: String?,
        forWrite: Boolean,
    ): MidnightContract = MidnightContract.create(sdk.config) {
        name = NAME
        contractJs = context.assets.open(CONTRACT_JS_ASSET)
        if (address != null) this.address = address
        if (forWrite) {
            coinPublicKey = sdk.coinPublicKey
            circuitVerifierKeys = loadVerifierKeys(context)
            witness(WITNESS_ADMIN_SECRET) { adminSecretWitness() }
        }
    }

    private fun adminSecretWitness(): WitnessResult =
        WitnessResult(null, ADMIN_SECRET)

    private suspend fun stageKeysAndHandle(
        context: Context,
        sdk: MidnightSdk,
        address: String?,
    ): MidnightContract {
        installProvingKeys(context)
        return buildHandle(context, sdk, address = address, forWrite = true)
    }

    suspend fun deploy(
        context: Context,
        sdk: MidnightSdk,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ): String {
        val handle = stageKeysAndHandle(context, sdk, address = null)
        return handle.deploy(onProgress = onProgress).contractAddress
    }

    suspend fun createPoll(
        context: Context,
        sdk: MidnightSdk,
        address: String,
        question: String,
        options: String,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ) {
        val handle = stageKeysAndHandle(context, sdk, address = address)
        handle.call(CIRCUIT_CREATE_POLL, question, options, onProgress = onProgress)
    }

    suspend fun castVote(
        context: Context,
        sdk: MidnightSdk,
        address: String,
        optionIdx: Long,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ) {
        val handle = stageKeysAndHandle(context, sdk, address = address)
        handle.call(CIRCUIT_CAST_VOTE, optionIdx, onProgress = onProgress)
    }

    suspend fun closePoll(
        context: Context,
        sdk: MidnightSdk,
        address: String,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ) {
        val handle = stageKeysAndHandle(context, sdk, address = address)
        handle.call(CIRCUIT_CLOSE_POLL, onProgress = onProgress)
    }

    fun buildReadHandle(context: Context, sdk: MidnightSdk, address: String): MidnightContract =
        buildHandle(context, sdk, address = address, forWrite = false)

    data class VotingLedger(
        val pollQuestion: String,
        val pollOptions: String,
        val adminKeyHash: ByteArray,
        val voteCount: Long,
        val tally0: Long,
        val tally1: Long,
        val tally2: Long,
        val tally3: Long,
        val pollClosed: Boolean,
    )

    suspend fun readLedger(handle: MidnightContract): VotingLedger {
        val ledger = handle.ledger()
        return VotingLedger(
            pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
            pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
            adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
            voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
            tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
            tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
            tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
            tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
            pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
        )
    }

    fun observeLedger(handle: MidnightContract): Flow<VotingLedger> =
        handle.observeLedger().map { ledger ->
            VotingLedger(
                pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
                pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
                adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
                voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
                tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
                tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
                tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
                tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
                pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
            )
        }
}
Critical pattern:
object
vs
class
— this is a singleton, not a per-instance class. The
ADMIN_SECRET
is hardcoded as
ByteArray(32) { 0x42 }
(all 0x42 bytes). Every deploy from the same APK uses the same admin secret. Anyone who decompiles the APK gets the admin key for every deployed poll.
WitnessResult(null, ADMIN_SECRET)
: The first parameter is
privateState: Any?
—here
null
because no private state is needed. The second is the witness byte array.
WitnessResult
wraps both into a single object returned by the witness function.
MidnightContract.create(sdk.config) { ... }
DSL builder:
  • name
    : used as a key for the contract JS asset lookup
  • contractJs
    : an
    InputStream
    to the contract JS file
  • address
    : the on-chain address (null for deploy)
  • coinPublicKey
    : needed for write operations (signing)
  • circuitVerifierKeys
    : map of circuit name → verifier key bytes
  • witness(name) { ... }
    : registers a witness provider
Asset path convention: The contract JS goes in
assets/runtime/
and verifier keys go in
assets/keys/
. The
kuiraContract
Gradle plugin copies them there automatically from the managed output directory.
Failure mode:
IllegalStateException: Contract JS not found at assets/runtime/voting-contract.js
. The asset wasn't synced. Run
./gradlew :app:assembleDebug
to trigger the sync, then check
app/src/main/assets/
exists with the right files.
Failure mode:
getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32)
throws if
adminKeyHash
hasn't been set yet (before
createPoll
). The contract doesn't initialize
adminKeyHash
in the constructor, so it may be all-zeros or an undefined value. The actual contract sets it in
createPoll
. The UI must handle
adminKeyHash
being absent.
这是核心合约包装类。每个细节都很重要。
kotlin
package com.kuiralabs.starter.counter.data

import android.content.Context
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.compact.WitnessResult
import com.midnight.kuira.core.compact.proving.ProvingKeyManager
import com.midnight.kuira.sdk.MidnightSdk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.MessageDigest

internal object VotingContract {

    private const val NAME = "voting"
    private const val CIRCUIT_CREATE_POLL = "createPoll"
    private const val CIRCUIT_CAST_VOTE = "castVote"
    private const val CIRCUIT_CLOSE_POLL = "closePoll"
    private const val WITNESS_ADMIN_SECRET = "adminSecret"

    private const val LEDGER_FIELD_POLL_QUESTION = "pollQuestion"
    private const val LEDGER_FIELD_POLL_OPTIONS = "pollOptions"
    private const val LEDGER_FIELD_ADMIN_KEY_HASH = "adminKeyHash"
    private const val LEDGER_FIELD_VOTE_COUNT = "voteCount"
    private const val LEDGER_FIELD_TALLY0 = "tally0"
    private const val LEDGER_FIELD_TALLY1 = "tally1"
    private const val LEDGER_FIELD_TALLY2 = "tally2"
    private const val LEDGER_FIELD_TALLY3 = "tally3"
    private const val LEDGER_FIELD_POLL_CLOSED = "pollClosed"

    private const val CONTRACT_JS_ASSET = "runtime/$NAME-contract.js"
    private const val VERIFIER_ASSET_CREATE = "keys/$CIRCUIT_CREATE_POLL.verifier"
    private const val VERIFIER_ASSET_CAST = "keys/$CIRCUIT_CAST_VOTE.verifier"
    private const val VERIFIER_ASSET_CLOSE = "keys/$CIRCUIT_CLOSE_POLL.verifier"

    private val ADMIN_SECRET = ByteArray(32) { 0x42 }

    private fun loadVerifierKeys(context: Context): Map<String, ByteArray> {
        val createVerifier = context.assets.open(VERIFIER_ASSET_CREATE).use { it.readBytes() }
        val castVerifier = context.assets.open(VERIFIER_ASSET_CAST).use { it.readBytes() }
        val closeVerifier = context.assets.open(VERIFIER_ASSET_CLOSE).use { it.readBytes() }
        return mapOf(
            CIRCUIT_CREATE_POLL to createVerifier,
            CIRCUIT_CAST_VOTE to castVerifier,
            CIRCUIT_CLOSE_POLL to closeVerifier,
        )
    }

    private fun installProvingKeys(context: Context) {
        ProvingKeyManager(context).installCircuitKeysFromAssets()
    }

    private fun buildHandle(
        context: Context,
        sdk: MidnightSdk,
        address: String?,
        forWrite: Boolean,
    ): MidnightContract = MidnightContract.create(sdk.config) {
        name = NAME
        contractJs = context.assets.open(CONTRACT_JS_ASSET)
        if (address != null) this.address = address
        if (forWrite) {
            coinPublicKey = sdk.coinPublicKey
            circuitVerifierKeys = loadVerifierKeys(context)
            witness(WITNESS_ADMIN_SECRET) { adminSecretWitness() }
        }
    }

    private fun adminSecretWitness(): WitnessResult =
        WitnessResult(null, ADMIN_SECRET)

    private suspend fun stageKeysAndHandle(
        context: Context,
        sdk: MidnightSdk,
        address: String?,
    ): MidnightContract {
        installProvingKeys(context)
        return buildHandle(context, sdk, address = address, forWrite = true)
    }

    suspend fun deploy(
        context: Context,
        sdk: MidnightSdk,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ): String {
        val handle = stageKeysAndHandle(context, sdk, address = null)
        return handle.deploy(onProgress = onProgress).contractAddress
    }

    suspend fun createPoll(
        context: Context,
        sdk: MidnightSdk,
        address: String,
        question: String,
        options: String,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ) {
        val handle = stageKeysAndHandle(context, sdk, address = address)
        handle.call(CIRCUIT_CREATE_POLL, question, options, onProgress = onProgress)
    }

    suspend fun castVote(
        context: Context,
        sdk: MidnightSdk,
        address: String,
        optionIdx: Long,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ) {
        val handle = stageKeysAndHandle(context, sdk, address = address)
        handle.call(CIRCUIT_CAST_VOTE, optionIdx, onProgress = onProgress)
    }

    suspend fun closePoll(
        context: Context,
        sdk: MidnightSdk,
        address: String,
        onProgress: (suspend (ContractCallStage) -> Unit)? = null,
    ) {
        val handle = stageKeysAndHandle(context, sdk, address = address)
        handle.call(CIRCUIT_CLOSE_POLL, onProgress = onProgress)
    }

    fun buildReadHandle(context: Context, sdk: MidnightSdk, address: String): MidnightContract =
        buildHandle(context, sdk, address = address, forWrite = false)

    data class VotingLedger(
        val pollQuestion: String,
        val pollOptions: String,
        val adminKeyHash: ByteArray,
        val voteCount: Long,
        val tally0: Long,
        val tally1: Long,
        val tally2: Long,
        val tally3: Long,
        val pollClosed: Boolean,
    )

    suspend fun readLedger(handle: MidnightContract): VotingLedger {
        val ledger = handle.ledger()
        return VotingLedger(
            pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
            pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
            adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
            voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
            tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
            tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
            tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
            tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
            pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
        )
    }

    fun observeLedger(handle: MidnightContract): Flow<VotingLedger> =
        handle.observeLedger().map { ledger ->
            VotingLedger(
                pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
                pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
                adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
                voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
                tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
                tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
                tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
                tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
                pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
            )
        }
}
关键模式:
object
vs
class
— 这是一个单例,而非实例类。
ADMIN_SECRET
硬编码为
ByteArray(32) { 0x42 }
(所有字节为0x42)。同一个APK部署的所有投票都使用相同的管理员密钥。任何反编译APK的人都能获取所有已部署投票的管理员密钥。
WitnessResult(null, ADMIN_SECRET)
:第一个参数是
privateState: Any?
——此处为
null
,因为不需要私有状态。第二个参数是见证字节数组。
WitnessResult
将两者包装成一个由见证函数返回的对象。
MidnightContract.create(sdk.config) { ... }
DSL构建器:
  • name
    :用作合约JS资源查找的键
  • contractJs
    :合约JS文件的
    InputStream
  • address
    :链上地址(部署时为null)
  • coinPublicKey
    :写操作需要(签名)
  • circuitVerifierKeys
    :电路名称→验证密钥字节的映射
  • witness(name) { ... }
    :注册见证提供者
资源路径约定: 合约JS放在
assets/runtime/
,验证密钥放在
assets/keys/
kuiraContract
Gradle插件会自动从托管输出目录将它们复制到对应位置。
故障模式:
IllegalStateException: Contract JS not found at assets/runtime/voting-contract.js
。资源未同步。运行
./gradlew :app:assembleDebug
触发同步,然后检查
app/src/main/assets/
是否存在正确的文件。
故障模式:
getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32)
createPoll
调用前抛出错误。合约在构造函数中未初始化
adminKeyHash
,因此它可能全为0或未定义。实际合约在
createPoll
中设置该字段。UI必须处理
adminKeyHash
不存在的情况。

7.2)
data/ContractAddressStore.kt

7.2)
data/ContractAddressStore.kt

kotlin
package com.kuiralabs.starter.counter.data

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.midnight.kuira.core.network.MidnightNetwork
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ContractAddressStore @Inject constructor(
    @ApplicationContext context: Context,
) {

    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs = EncryptedSharedPreferences.create(
        context,
        "kuira-starter-contract-prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )

    fun get(network: MidnightNetwork, prefix: String = "voting"): String? =
        prefs.getString("$prefix.${network.name}", null)

    fun put(network: MidnightNetwork, prefix: String = "voting", address: String) {
        prefs.edit().putString("$prefix.${network.name}", address).apply()
    }

    fun clear(network: MidnightNetwork, prefix: String = "voting") {
        prefs.edit().remove("$prefix.${network.name}").apply()
    }
}
The default prefix is
"voting"
, matching the VotingContract name. Each network gets its own slot (e.g.
voting.UNDEPLOYED
), so switching networks in the wallet panel doesn't strand the previous deploy.
EncryptedSharedPreferences: Uses AES256-GCM for the master key and AES256-SIV for key encryption. The
MasterKey.Builder
uses Android Keystore under the hood, so the encryption keys are hardware-backed on devices with a TEE.
Failure mode:
MasterKey.Builder.setKeyScheme
requires API 23+. The minSdk is 30, so this is fine. But if you backport to lower minSdk, use
MasterKey.DEFAULT_MASTER_KEY_ALIAS
instead.

kotlin
package com.kuiralabs.starter.counter.data

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.midnight.kuira.core.network.MidnightNetwork
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ContractAddressStore @Inject constructor(
    @ApplicationContext context: Context,
) {

    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs = EncryptedSharedPreferences.create(
        context,
        "kuira-starter-contract-prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )

    fun get(network: MidnightNetwork, prefix: String = "voting"): String? =
        prefs.getString("$prefix.${network.name}", null)

    fun put(network: MidnightNetwork, prefix: String = "voting", address: String) {
        prefs.edit().putString("$prefix.${network.name}", address).apply()
    }

    fun clear(network: MidnightNetwork, prefix: String = "voting") {
        prefs.edit().remove("$prefix.${network.name}").apply()
    }
}
默认前缀为
"voting"
,与VotingContract名称匹配。每个网络都有自己的存储槽(如
voting.UNDEPLOYED
),因此在钱包面板中切换网络不会影响之前的部署。
EncryptedSharedPreferences: 使用AES256-GCM作为主密钥,AES256-SIV作为密钥加密。
MasterKey.Builder
在底层使用Android Keystore,因此在带有TEE的设备上,加密密钥是硬件支持的。
故障模式:
MasterKey.Builder.setKeyScheme
需要API 23+。本项目minSdk为30,因此没问题。但如果向后兼容到更低的minSdk,请使用
MasterKey.DEFAULT_MASTER_KEY_ALIAS
替代。

8) UI Layer

8) UI层

8.1)
ui/VotingUiState.kt

8.1)
ui/VotingUiState.kt

kotlin
package com.kuiralabs.starter.counter.ui

sealed interface VotingUiState {
    data object NotReady : VotingUiState
    data object ReadyToDeploy : VotingUiState
    data class Deployed(
        val address: String,
        val pollQuestion: String?,
        val pollOptions: List<String>?,
        val adminKeyHash: ByteArray?,
        val voteCount: Long?,
        val tallies: List<Long>?,
        val pollClosed: Boolean?,
        val hasVoted: Boolean,
    ) : VotingUiState
}
Note:
adminKeyHash: ByteArray?
— it's nullable because the contract doesn't set it until
createPoll
is called. Before that, the ledger doesn't have a meaningful value for this field.
hasVoted
is a local boolean
— it's set to
true
in the ViewModel after a successful
castVote()
. It's NOT persisted across process death. A ViewModel destroy (e.g., activity kill due to memory pressure) resets it to
false
. There's no persisted "has voted" flag in this codebase.
kotlin
package com.kuiralabs.starter.counter.ui

sealed interface VotingUiState {
    data object NotReady : VotingUiState
    data object ReadyToDeploy : VotingUiState
    data class Deployed(
        val address: String,
        val pollQuestion: String?,
        val pollOptions: List<String>?,
        val adminKeyHash: ByteArray?,
        val voteCount: Long?,
        val tallies: List<Long>?,
        val pollClosed: Boolean?,
        val hasVoted: Boolean,
    ) : VotingUiState
}
注意:
adminKeyHash: ByteArray?
— 它是可空的,因为合约在
createPoll
调用前不会设置该字段。在此之前,账本中该字段没有有意义的值。
hasVoted
是本地布尔值
— 在ViewModel中成功调用
castVote()
后设置为
true
。它不会在进程重启后持久化。ViewModel销毁(如因内存压力导致Activity被杀)会将其重置为
false
。本代码库中没有持久化的"已投票"标志。

8.2)
ui/VotingViewModel.kt

8.2)
ui/VotingViewModel.kt

kotlin
package com.kuiralabs.starter.counter.ui

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuiralabs.starter.counter.data.ContractAddressStore
import com.kuiralabs.starter.counter.data.VotingContract
import com.kuiralabs.starter.counter.data.VotingContract.VotingLedger
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.network.MidnightNetwork
import com.midnight.kuira.sdk.MidnightSdk
import com.midnight.kuira.sdk.walletruntime.MidnightSdkProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class VotingViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val sdkProvider: MidnightSdkProvider,
    private val addressStore: ContractAddressStore,
) : ViewModel() {

    private val _state = MutableStateFlow<VotingUiState>(VotingUiState.NotReady)
    val state: StateFlow<VotingUiState> = _state.asStateFlow()

    private val _busy = MutableStateFlow(false)
    val busy: StateFlow<Boolean> = _busy.asStateFlow()

    private val _callStage = MutableStateFlow<ContractCallStage?>(null)
    val callStage: StateFlow<ContractCallStage?> = _callStage.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    private val _question = MutableStateFlow("")
    val question: StateFlow<String> = _question.asStateFlow()

    private val _options = MutableStateFlow("")
    val options: StateFlow<String> = _options.asStateFlow()

    val selectedNetwork: StateFlow<MidnightNetwork> get() = sdkProvider.selectedNetwork
    fun selectNetwork(network: MidnightNetwork) = sdkProvider.selectNetwork(network)

    private var ledgerJob: Job? = null

    init {
        viewModelScope.launch {
            sdkProvider.sdk.combine(sdkProvider.selectedNetwork) { sdk, net -> sdk to net }
                .collect { (sdk, network) -> recomputeState(sdk, network) }
        }
    }

    private fun recomputeState(sdk: MidnightSdk?, network: MidnightNetwork) {
        val persisted = addressStore.get(network, "voting")
        val next = when {
            sdk == null -> VotingUiState.NotReady
            persisted == null -> VotingUiState.ReadyToDeploy
            else -> VotingUiState.Deployed(
                address = persisted,
                pollQuestion = null,
                pollOptions = null,
                adminKeyHash = null,
                voteCount = null,
                tallies = null,
                pollClosed = null,
                hasVoted = false,
            )
        }
        _state.value = next
        if (sdk != null && persisted != null) startObserving(sdk, persisted) else stopObserving()
    }

    fun deploy() {
        val sdk = sdkProvider.sdk.value ?: return
        val network = sdkProvider.selectedNetwork.value
        runAction {
            val address = VotingContract.deploy(context, sdk) { _callStage.value = it }
            addressStore.put(network, "voting", address)
            recomputeState(sdk, network)
        }
    }

    fun disconnect() {
        val network = sdkProvider.selectedNetwork.value
        addressStore.clear(network, "voting")
        recomputeState(sdkProvider.sdk.value, network)
    }

    fun updateQuestion(value: String) { _question.value = value }
    fun updateOptions(value: String) { _options.value = value }

    fun createPoll() {
        val sdk = sdkProvider.sdk.value ?: return
        val address = (state.value as? VotingUiState.Deployed)?.address ?: return
        val q = _question.value.trim()
        val o = _options.value.trim()
        if (q.isBlank() || o.isBlank()) return
        runAction {
            VotingContract.createPoll(context, sdk, address, q, o) { _callStage.value = it }
            readAndUpdateLedger(sdk, address)
        }
    }

    fun castVote(optionIdx: Int) {
        val sdk = sdkProvider.sdk.value ?: return
        val address = (state.value as? VotingUiState.Deployed)?.address ?: return
        runAction {
            VotingContract.castVote(context, sdk, address, optionIdx.toLong()) { _callStage.value = it }
            readAndUpdateLedger(sdk, address)
            _state.update { current ->
                if (current is VotingUiState.Deployed && current.address == address) {
                    current.copy(hasVoted = true)
                } else current
            }
        }
    }

    fun closePoll() {
        val sdk = sdkProvider.sdk.value ?: return
        val address = (state.value as? VotingUiState.Deployed)?.address ?: return
        runAction {
            VotingContract.closePoll(context, sdk, address) { _callStage.value = it }
            readAndUpdateLedger(sdk, address)
        }
    }

    private suspend fun readAndUpdateLedger(sdk: MidnightSdk, address: String) {
        val fresh = VotingContract.readLedger(readHandleFor(sdk, address))
        applyLedger(fresh, address)
    }

    private fun runAction(block: suspend () -> Unit) {
        viewModelScope.launch {
            _busy.value = true
            _error.value = null
            try {
                block()
            } catch (t: Throwable) {
                _error.value = t.message ?: t::class.simpleName ?: "Unknown error"
            } finally {
                _busy.value = false
                _callStage.value = null
            }
        }
    }

    private var readHandle: MidnightContract? = null
    private var readHandleAddress: String? = null

    private fun readHandleFor(sdk: MidnightSdk, address: String): MidnightContract {
        if (readHandle == null || readHandleAddress != address) {
            readHandle = VotingContract.buildReadHandle(context, sdk, address)
            readHandleAddress = address
        }
        return readHandle!!
    }

    private fun startObserving(sdk: MidnightSdk, address: String) {
        ledgerJob?.cancel()
        ledgerJob = viewModelScope.launch {
            val handle = readHandleFor(sdk, address)
            VotingContract.observeLedger(handle)
                .catch { }
                .collect { fresh -> applyLedger(fresh, address) }
        }
    }

    private fun applyLedger(fresh: VotingLedger, address: String) {
        val optList = fresh.pollOptions.split(",").map { it.trim() }.filter { it.isNotBlank() }
        _state.update { current ->
            if (current is VotingUiState.Deployed && current.address == address) {
                current.copy(
                    pollQuestion = fresh.pollQuestion,
                    pollOptions = optList,
                    adminKeyHash = fresh.adminKeyHash,
                    voteCount = fresh.voteCount,
                    tallies = listOf(fresh.tally0, fresh.tally1, fresh.tally2, fresh.tally3),
                    pollClosed = fresh.pollClosed,
                )
            } else current
        }
    }

    private fun stopObserving() {
        ledgerJob?.cancel()
        ledgerJob = null
        readHandle = null
        readHandleAddress = null
    }
}
Key patterns:
  • Network selection:
    sdkProvider.selectedNetwork
    is a
    StateFlow<MidnightNetwork>
    . The SDK's
    PanelBar
    composable binds to it. The ViewModel reads it to pass to
    ContractAddressStore
    .
  • recomputeState
    vs
    applyLedger
    :
    recomputeState
    is called when the SDK or network changes (e.g., wallet unlock, network switch). It resets to initial state.
    applyLedger
    is called when the on-chain ledger updates (via
    observeLedger()
    or manual
    readLedger()
    ). It preserves the Deployed state and fills in live data.
  • readHandle
    caching:
    A
    MidnightContract
    handle for read-only access is cached per address. When the address changes (e.g., deploying a new contract), the old handle is discarded.
  • runAction
    error handling:
    Catches ALL exceptions and surfaces the message. A failed
    closePoll
    (wrong admin secret) throws
    CircuitExecutionException("Only admin can close poll")
    . The
    _callStage
    progress is cleared in the
    finally
    block.
  • ContractCallStage
    : The SDK defines a sealed interface for deploy/call progress. It's surfaced via the
    ContractCallProgressBar
    composable. The ViewModel exposes it as
    callStage: StateFlow
    .
  • observeLedger().catch { }
    : Silently swallows ledger observation errors. If the connection drops during observation, it won't crash the ViewModel. The UI will just stop updating until the next user action triggers
    readAndUpdateLedger
    .
kotlin
package com.kuiralabs.starter.counter.ui

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuiralabs.starter.counter.data.ContractAddressStore
import com.kuiralabs.starter.counter.data.VotingContract
import com.kuiralabs.starter.counter.data.VotingContract.VotingLedger
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.network.MidnightNetwork
import com.midnight.kuira.sdk.MidnightSdk
import com.midnight.kuira.sdk.walletruntime.MidnightSdkProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class VotingViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val sdkProvider: MidnightSdkProvider,
    private val addressStore: ContractAddressStore,
) : ViewModel() {

    private val _state = MutableStateFlow<VotingUiState>(VotingUiState.NotReady)
    val state: StateFlow<VotingUiState> = _state.asStateFlow()

    private val _busy = MutableStateFlow(false)
    val busy: StateFlow<Boolean> = _busy.asStateFlow()

    private val _callStage = MutableStateFlow<ContractCallStage?>(null)
    val callStage: StateFlow<ContractCallStage?> = _callStage.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    private val _question = MutableStateFlow("")
    val question: StateFlow<String> = _question.asStateFlow()

    private val _options = MutableStateFlow("")
    val options: StateFlow<String> = _options.asStateFlow()

    val selectedNetwork: StateFlow<MidnightNetwork> get() = sdkProvider.selectedNetwork
    fun selectNetwork(network: MidnightNetwork) = sdkProvider.selectNetwork(network)

    private var ledgerJob: Job? = null

    init {
        viewModelScope.launch {
            sdkProvider.sdk.combine(sdkProvider.selectedNetwork) { sdk, net -> sdk to net }
                .collect { (sdk, network) -> recomputeState(sdk, network) }
        }
    }

    private fun recomputeState(sdk: MidnightSdk?, network: MidnightNetwork) {
        val persisted = addressStore.get(network, "voting")
        val next = when {
            sdk == null -> VotingUiState.NotReady
            persisted == null -> VotingUiState.ReadyToDeploy
            else -> VotingUiState.Deployed(
                address = persisted,
                pollQuestion = null,
                pollOptions = null,
                adminKeyHash = null,
                voteCount = null,
                tallies = null,
                pollClosed = null,
                hasVoted = false,
            )
        }
        _state.value = next
        if (sdk != null && persisted != null) startObserving(sdk, persisted) else stopObserving()
    }

    fun deploy() {
        val sdk = sdkProvider.sdk.value ?: return
        val network = sdkProvider.selectedNetwork.value
        runAction {
            val address = VotingContract.deploy(context, sdk) { _callStage.value = it }
            addressStore.put(network, "voting", address)
            recomputeState(sdk, network)
        }
    }

    fun disconnect() {
        val network = sdkProvider.selectedNetwork.value
        addressStore.clear(network, "voting")
        recomputeState(sdkProvider.sdk.value, network)
    }

    fun updateQuestion(value: String) { _question.value = value }
    fun updateOptions(value: String) { _options.value = value }

    fun createPoll() {
        val sdk = sdkProvider.sdk.value ?: return
        val address = (state.value as? VotingUiState.Deployed)?.address ?: return
        val q = _question.value.trim()
        val o = _options.value.trim()
        if (q.isBlank() || o.isBlank()) return
        runAction {
            VotingContract.createPoll(context, sdk, address, q, o) { _callStage.value = it }
            readAndUpdateLedger(sdk, address)
        }
    }

    fun castVote(optionIdx: Int) {
        val sdk = sdkProvider.sdk.value ?: return
        val address = (state.value as? VotingUiState.Deployed)?.address ?: return
        runAction {
            VotingContract.castVote(context, sdk, address, optionIdx.toLong()) { _callStage.value = it }
            readAndUpdateLedger(sdk, address)
            _state.update { current ->
                if (current is VotingUiState.Deployed && current.address == address) {
                    current.copy(hasVoted = true)
                } else current
            }
        }
    }

    fun closePoll() {
        val sdk = sdkProvider.sdk.value ?: return
        val address = (state.value as? VotingUiState.Deployed)?.address ?: return
        runAction {
            VotingContract.closePoll(context, sdk, address) { _callStage.value = it }
            readAndUpdateLedger(sdk, address)
        }
    }

    private suspend fun readAndUpdateLedger(sdk: MidnightSdk, address: String) {
        val fresh = VotingContract.readLedger(readHandleFor(sdk, address))
        applyLedger(fresh, address)
    }

    private fun runAction(block: suspend () -> Unit) {
        viewModelScope.launch {
            _busy.value = true
            _error.value = null
            try {
                block()
            } catch (t: Throwable) {
                _error.value = t.message ?: t::class.simpleName ?: "Unknown error"
            } finally {
                _busy.value = false
                _callStage.value = null
            }
        }
    }

    private var readHandle: MidnightContract? = null
    private var readHandleAddress: String? = null

    private fun readHandleFor(sdk: MidnightSdk, address: String): MidnightContract {
        if (readHandle == null || readHandleAddress != address) {
            readHandle = VotingContract.buildReadHandle(context, sdk, address)
            readHandleAddress = address
        }
        return readHandle!!
    }

    private fun startObserving(sdk: MidnightSdk, address: String) {
        ledgerJob?.cancel()
        ledgerJob = viewModelScope.launch {
            val handle = readHandleFor(sdk, address)
            VotingContract.observeLedger(handle)
                .catch { }
                .collect { fresh -> applyLedger(fresh, address) }
        }
    }

    private fun applyLedger(fresh: VotingLedger, address: String) {
        val optList = fresh.pollOptions.split(",").map { it.trim() }.filter { it.isNotBlank() }
        _state.update { current ->
            if (current is VotingUiState.Deployed && current.address == address) {
                current.copy(
                    pollQuestion = fresh.pollQuestion,
                    pollOptions = optList,
                    adminKeyHash = fresh.adminKeyHash,
                    voteCount = fresh.voteCount,
                    tallies = listOf(fresh.tally0, fresh.tally1, fresh.tally2, fresh.tally3),
                    pollClosed = fresh.pollClosed,
                )
            } else current
        }
    }

    private fun stopObserving() {
        ledgerJob?.cancel()
        ledgerJob = null
        readHandle = null
        readHandleAddress = null
    }
}
关键模式:
  • 网络选择:
    sdkProvider.selectedNetwork
    StateFlow<MidnightNetwork>
    。SDK的
    PanelBar
    可组合项会绑定到它。ViewModel读取它并传递给
    ContractAddressStore
  • recomputeState
    vs
    applyLedger
    recomputeState
    在SDK或网络变化时调用(如钱包解锁、网络切换)。它会重置为初始状态。
    applyLedger
    在链上账本更新时调用(通过
    observeLedger()
    或手动
    readLedger()
    )。它会保留Deployed状态并填充实时数据。
  • readHandle
    缓存:
    为只读访问的
    MidnightContract
    句柄按地址缓存。当地址变化时(如部署新合约),旧句柄会被丢弃。
  • runAction
    错误处理:
    捕获所有异常并显示错误消息。失败的
    closePoll
    (错误的管理员密钥)会抛出
    CircuitExecutionException("Only admin can close poll")
    _callStage
    进度会在
    finally
    块中清除。
  • ContractCallStage
    SDK定义了一个密封接口用于部署/调用进度。它通过
    ContractCallProgressBar
    可组合项展示。ViewModel将其暴露为
    callStage: StateFlow
  • observeLedger().catch { }
    静默忽略账本观察错误。如果观察期间连接断开,不会导致ViewModel崩溃。UI会停止更新,直到下一次用户操作触发
    readAndUpdateLedger

8.3)
ui/VotingScreen.kt

8.3)
ui/VotingScreen.kt

kotlin
package com.kuiralabs.starter.counter.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.PanelBar

@Composable
fun VotingScreen(
    modifier: Modifier = Modifier,
    viewModel: VotingViewModel = hiltViewModel(),
) {
    val selectedNetwork by viewModel.selectedNetwork.collectAsState()

    Box(modifier = modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .verticalScroll(rememberScrollState())
                .padding(16.dp)
                .padding(top = 56.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Text(
                text = "Voting",
                style = MaterialTheme.typography.headlineMedium,
                color = MaterialTheme.colorScheme.primary,
            )
            VotingCard()
        }

        PanelBar(
            floating = true,
            network = selectedNetwork,
            onNetworkChange = viewModel::selectNetwork,
        )
    }
}
PanelBar(floating=true)
renders a floating sigil chip (left) and wallet chip (right). It's the SDK's recommended pairing — the sigil chip handles forge/create/import, the wallet chip shows balance and provides send/receive/dust registration.
WindowInsets.safeDrawing
ensures the content avoids display cutouts and system bars.
kotlin
package com.kuiralabs.starter.counter.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.PanelBar

@Composable
fun VotingScreen(
    modifier: Modifier = Modifier,
    viewModel: VotingViewModel = hiltViewModel(),
) {
    val selectedNetwork by viewModel.selectedNetwork.collectAsState()

    Box(modifier = modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .verticalScroll(rememberScrollState())
                .padding(16.dp)
                .padding(top = 56.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Text(
                text = "投票",
                style = MaterialTheme.typography.headlineMedium,
                color = MaterialTheme.colorScheme.primary,
            )
            VotingCard()
        }

        PanelBar(
            floating = true,
            network = selectedNetwork,
            onNetworkChange = viewModel::selectNetwork,
        )
    }
}
PanelBar(floating=true)
渲染一个浮动的sigil芯片(左侧)和钱包芯片(右侧)。这是SDK推荐的组合——sigil芯片处理生成/创建/导入,钱包芯片显示余额并提供发送/接收/dust注册功能。
WindowInsets.safeDrawing
确保内容避开显示切口和系统栏。

8.4)
ui/VotingCard.kt

8.4)
ui/VotingCard.kt

kotlin
package com.kuiralabs.starter.counter.ui

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.height
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.ContractCallProgressBar

@Composable
fun VotingCard(
    modifier: Modifier = Modifier,
    viewModel: VotingViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsState()
    val busy by viewModel.busy.collectAsState()
    val callStage by viewModel.callStage.collectAsState()
    val error by viewModel.error.collectAsState()
    val question by viewModel.question.collectAsState()
    val options by viewModel.options.collectAsState()

    Card(
        modifier = modifier.fillMaxWidth(),
        shape = RoundedCornerShape(16.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surface
        ),
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(20.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Header(state)

            when (val s = state) {
                VotingUiState.NotReady -> NotReadyBody()
                VotingUiState.ReadyToDeploy -> ReadyToDeployBody(busy = busy, onDeploy = viewModel::deploy)
                is VotingUiState.Deployed -> DeployedBody(
                    state = s, busy = busy,
                    question = question, onQuestionChange = viewModel::updateQuestion,
                    options = options, onOptionsChange = viewModel::updateOptions,
                    onCreatePoll = viewModel::createPoll, onCastVote = viewModel::castVote,
                    onClosePoll = viewModel::closePoll, onDeployNew = viewModel::deploy,
                    onDisconnect = viewModel::disconnect,
                )
            }

            if (busy) {
                if (callStage == null) {
                    Box(
                        modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
                        contentAlignment = Alignment.Center,
                    ) { CircularProgressIndicator() }
                } else {
                    ContractCallProgressBar(
                        stage = callStage,
                        accent = MaterialTheme.colorScheme.primary,
                        trackColor = MaterialTheme.colorScheme.surfaceVariant,
                        labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                }
            }

            val err = error
            if (err != null) {
                Surface(
                    shape = RoundedCornerShape(8.dp),
                    color = MaterialTheme.colorScheme.errorContainer,
                    modifier = Modifier.fillMaxWidth(),
                ) {
                    Text(
                        text = err,
                        modifier = Modifier.padding(12.dp),
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onErrorContainer,
                    )
                }
            }
        }
    }
}

@Composable
private fun Header(state: VotingUiState) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(
            text = "Voting",
            style = MaterialTheme.typography.titleLarge,
            fontWeight = FontWeight.Bold,
            color = MaterialTheme.colorScheme.primary,
        )
        StatusBadge(state)
    }
}

@Composable
private fun StatusBadge(state: VotingUiState) {
    val (label, dotColor) = when (state) {
        VotingUiState.NotReady -> "Not connected" to MaterialTheme.colorScheme.error
        VotingUiState.ReadyToDeploy -> "Ready" to MaterialTheme.colorScheme.tertiary
        is VotingUiState.Deployed -> "Active" to MaterialTheme.colorScheme.primary
    }
    Surface(
        shape = RoundedCornerShape(20.dp),
        color = dotColor.copy(alpha = 0.12f),
    ) {
        Row(
            modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                modifier = Modifier.size(8.dp).clip(CircleShape).background(dotColor)
            )
            Spacer(Modifier.width(6.dp))
            Text(
                text = label,
                style = MaterialTheme.typography.labelSmall,
                fontWeight = FontWeight.SemiBold,
                color = dotColor,
            )
        }
    }
}

@Composable
private fun NotReadyBody() {
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant,
        modifier = Modifier.fillMaxWidth(),
    ) {
        Text(
            text = "Forge a sigil above, then fund the wallet and register dust.",
            modifier = Modifier.padding(16.dp),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

@Composable
private fun ReadyToDeployBody(busy: Boolean, onDeploy: () -> Unit) {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Surface(
            shape = RoundedCornerShape(12.dp),
            color = MaterialTheme.colorScheme.primaryContainer,
            modifier = Modifier.fillMaxWidth(),
        ) {
            Text(
                text = "Wallet ready. Deploy a new voting contract to the current network.",
                modifier = Modifier.padding(16.dp),
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onPrimaryContainer,
            )
        }
        Button(
            onClick = onDeploy, enabled = !busy,
            modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
        ) { Text(text = "Deploy contract") }
    }
}

@Composable
private fun DeployedBody(
    state: VotingUiState.Deployed, busy: Boolean,
    question: String, onQuestionChange: (String) -> Unit,
    options: String, onOptionsChange: (String) -> Unit,
    onCreatePoll: () -> Unit, onCastVote: (Int) -> Unit,
    onClosePoll: () -> Unit, onDeployNew: () -> Unit, onDisconnect: () -> Unit,
) {
    AddressSection(state.address)

    if (state.pollQuestion.isNullOrBlank()) {
        CreatePollSection(
            question = question, onQuestionChange = onQuestionChange,
            options = options, onOptionsChange = onOptionsChange,
            onCreatePoll = onCreatePoll, busy = busy,
        )
    } else {
        PollResultsSection(
            question = state.pollQuestion ?: "",
            options = state.pollOptions ?: emptyList(),
            tallies = state.tallies ?: listOf(0, 0, 0, 0),
            totalVotes = state.voteCount ?: 0,
            hasVoted = state.hasVoted, pollClosed = state.pollClosed == true,
            onCastVote = onCastVote, onClosePoll = onClosePoll, busy = busy,
        )
    }

    HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)

    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        OutlinedButton(
            onClick = onDeployNew, enabled = !busy,
            modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
        ) { Text(text = "Deploy new") }
        OutlinedButton(
            onClick = onDisconnect, enabled = !busy,
            modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
        ) { Text(text = "Disconnect") }
    }

    Text(
        text = "Results update live from chain.",
        style = MaterialTheme.typography.labelSmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        textAlign = TextAlign.Center,
        modifier = Modifier.fillMaxWidth(),
    )
}

@Composable
private fun AddressSection(address: String) {
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(modifier = Modifier.padding(12.dp)) {
            Text(
                text = "Contract",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            Spacer(Modifier.height(2.dp))
            Text(
                text = address,
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurface,
                maxLines = 1, overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
private fun CreatePollSection(
    question: String, onQuestionChange: (String) -> Unit,
    options: String, onOptionsChange: (String) -> Unit,
    onCreatePoll: () -> Unit, busy: Boolean,
) {
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Text(
                text = "Create a poll",
                style = MaterialTheme.typography.titleSmall,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                text = "No poll exists yet. Fill in the details below to create one.",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            OutlinedTextField(
                value = question, onValueChange = onQuestionChange,
                label = { Text("Poll question") },
                placeholder = { Text("e.g. What's the best programming language?") },
                singleLine = true, shape = RoundedCornerShape(8.dp),
                modifier = Modifier.fillMaxWidth(),
            )
            OutlinedTextField(
                value = options, onValueChange = onOptionsChange,
                label = { Text("Options") },
                placeholder = { Text("e.g. Rust, TypeScript, Kotlin, Go") },
                supportingText = { Text("Comma-separated, max 4 options") },
                singleLine = true, shape = RoundedCornerShape(8.dp),
                modifier = Modifier.fillMaxWidth(),
            )
            Button(
                onClick = onCreatePoll,
                enabled = !busy && question.isNotBlank() && options.isNotBlank(),
                modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
            ) { Text(text = "Create poll") }
        }
    }
}

@Composable
private fun PollResultsSection(
    question: String, options: List<String>, tallies: List<Long>,
    totalVotes: Long, hasVoted: Boolean, pollClosed: Boolean,
    onCastVote: (Int) -> Unit, onClosePoll: () -> Unit, busy: Boolean,
) {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Text(
            text = question,
            style = MaterialTheme.typography.headlineSmall,
            fontWeight = FontWeight.Bold,
        )

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            if (pollClosed) {
                Surface(
                    shape = RoundedCornerShape(20.dp),
                    color = MaterialTheme.colorScheme.errorContainer,
                ) {
                    Text(
                        text = "Closed",
                        modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onErrorContainer,
                    )
                }
            }
            if (hasVoted && !pollClosed) {
                Surface(
                    shape = RoundedCornerShape(20.dp),
                    color = MaterialTheme.colorScheme.primaryContainer,
                ) {
                    Text(
                        text = "You voted",
                        modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onPrimaryContainer,
                    )
                }
            }
            if (totalVotes > 0) {
                Text(
                    text = "$totalVotes vote${if (totalVotes != 1L) \"s\" else \"\"}",
                    style = MaterialTheme.typography.labelMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    modifier = Modifier.align(Alignment.CenterVertically),
                )
            }
        }

        val maxTally = tallies.maxOrNull()?.coerceAtLeast(1) ?: 1
        options.forEachIndexed { idx, option ->
            val tally = tallies.getOrElse(idx) { 0L }
            val fraction = if (totalVotes > 0) tally.toFloat() / totalVotes else 0f
            OptionCard(
                label = option, tally = tally, fraction = fraction,
                totalVotes = totalVotes,
                isWinning = tally > 0 && tally == tallies.maxOrNull(),
                canVote = !hasVoted && !pollClosed && !busy,
                onVote = { onCastVote(idx) },
            )
        }

        if (!pollClosed) {
            HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
            Surface(
                shape = RoundedCornerShape(12.dp),
                color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
                modifier = Modifier.fillMaxWidth(),
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                ) {
                    Text(
                        text = "Admin",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                    Button(
                        onClick = onClosePoll, enabled = !busy,
                        modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
                        colors = ButtonDefaults.buttonColors(
                            containerColor = MaterialTheme.colorScheme.error,
                            contentColor = MaterialTheme.colorScheme.onError,
                        ),
                    ) { Text(text = "Close poll") }
                }
            }
        }
    }
}

@Composable
private fun OptionCard(
    label: String, tally: Long, fraction: Float,
    totalVotes: Long, isWinning: Boolean, canVote: Boolean, onVote: () -> Unit,
) {
    val bgColor = when {
        isWinning && tally > 0 -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
        canVote -> MaterialTheme.colorScheme.surface
        else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
    }
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = bgColor,
        tonalElevation = if (canVote) 1.dp else 0.dp,
        modifier = Modifier
            .fillMaxWidth()
            .then(if (canVote) Modifier.clickable(onClick = onVote) else Modifier),
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text(
                    text = label,
                    style = MaterialTheme.typography.bodyLarge,
                    fontWeight = FontWeight.Medium,
                    modifier = Modifier.weight(1f),
                )
                Text(
                    text = if (tally > 0) "$tally" else "0",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold,
                    color = if (isWinning && tally > 0)
                        MaterialTheme.colorScheme.primary
                    else
                        MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }

            LinearProgressIndicator(
                progress = { fraction },
                modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
                color = if (isWinning && tally > 0)
                    MaterialTheme.colorScheme.primary
                else
                    MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
                trackColor = MaterialTheme.colorScheme.surfaceVariant,
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                Text(
                    text = if (totalVotes > 0) "${(fraction * 100).toInt()}%" else "-",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                if (canVote) {
                    Text(
                        text = "Tap to vote",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.primary,
                    )
                }
                if (isWinning && tally > 0) {
                    Text(
                        text = "Leading",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.primary,
                    )
                }
            }
        }
    }
}
kotlin
package com.kuiralabs.starter.counter.ui

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.height
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.ContractCallProgressBar

@Composable
fun VotingCard(
    modifier: Modifier = Modifier,
    viewModel: VotingViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsState()
    val busy by viewModel.busy.collectAsState()
    val callStage by viewModel.callStage.collectAsState()
    val error by viewModel.error.collectAsState()
    val question by viewModel.question.collectAsState()
    val options by viewModel.options.collectAsState()

    Card(
        modifier = modifier.fillMaxWidth(),
        shape = RoundedCornerShape(16.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surface
        ),
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(20.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Header(state)

            when (val s = state) {
                VotingUiState.NotReady -> NotReadyBody()
                VotingUiState.ReadyToDeploy -> ReadyToDeployBody(busy = busy, onDeploy = viewModel::deploy)
                is VotingUiState.Deployed -> DeployedBody(
                    state = s, busy = busy,
                    question = question, onQuestionChange = viewModel::updateQuestion,
                    options = options, onOptionsChange = viewModel::updateOptions,
                    onCreatePoll = viewModel::createPoll, onCastVote = viewModel::castVote,
                    onClosePoll = viewModel::closePoll, onDeployNew = viewModel::deploy,
                    onDisconnect = viewModel::disconnect,
                )
            }

            if (busy) {
                if (callStage == null) {
                    Box(
                        modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
                        contentAlignment = Alignment.Center,
                    ) { CircularProgressIndicator() }
                } else {
                    ContractCallProgressBar(
                        stage = callStage,
                        accent = MaterialTheme.colorScheme.primary,
                        trackColor = MaterialTheme.colorScheme.surfaceVariant,
                        labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                }
            }

            val err = error
            if (err != null) {
                Surface(
                    shape = RoundedCornerShape(8.dp),
                    color = MaterialTheme.colorScheme.errorContainer,
                    modifier = Modifier.fillMaxWidth(),
                ) {
                    Text(
                        text = err,
                        modifier = Modifier.padding(12.dp),
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onErrorContainer,
                    )
                }
            }
        }
    }
}

@Composable
private fun Header(state: VotingUiState) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(
            text = "投票",
            style = MaterialTheme.typography.titleLarge,
            fontWeight = FontWeight.Bold,
            color = MaterialTheme.colorScheme.primary,
        )
        StatusBadge(state)
    }
}

@Composable
private fun StatusBadge(state: VotingUiState) {
    val (label, dotColor) = when (state) {
        VotingUiState.NotReady -> "未连接" to MaterialTheme.colorScheme.error
        VotingUiState.ReadyToDeploy -> "就绪" to MaterialTheme.colorScheme.tertiary
        is VotingUiState.Deployed -> "活跃" to MaterialTheme.colorScheme.primary
    }
    Surface(
        shape = RoundedCornerShape(20.dp),
        color = dotColor.copy(alpha = 0.12f),
    ) {
        Row(
            modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                modifier = Modifier.size(8.dp).clip(CircleShape).background(dotColor)
            )
            Spacer(Modifier.width(6.dp))
            Text(
                text = label,
                style = MaterialTheme.typography.labelSmall,
                fontWeight = FontWeight.SemiBold,
                color = dotColor,
            )
        }
    }
}

@Composable
private fun NotReadyBody() {
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant,
        modifier = Modifier.fillMaxWidth(),
    ) {
        Text(
            text = "请点击上方生成sigil,然后为钱包充值并注册dust。",
            modifier = Modifier.padding(16.dp),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

@Composable
private fun ReadyToDeployBody(busy: Boolean, onDeploy: () -> Unit) {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Surface(
            shape = RoundedCornerShape(12.dp),
            color = MaterialTheme.colorScheme.primaryContainer,
            modifier = Modifier.fillMaxWidth(),
        ) {
            Text(
                text = "钱包已就绪。请在当前网络部署新的投票合约。",
                modifier = Modifier.padding(16.dp),
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onPrimaryContainer,
            )
        }
        Button(
            onClick = onDeploy, enabled = !busy,
            modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
        ) { Text(text = "部署合约") }
    }
}

@Composable
private fun DeployedBody(
    state: VotingUiState.Deployed, busy: Boolean,
    question: String, onQuestionChange: (String) -> Unit,
    options: String, onOptionsChange: (String) -> Unit,
    onCreatePoll: () -> Unit, onCastVote: (Int) -> Unit,
    onClosePoll: () -> Unit, onDeployNew: () -> Unit, onDisconnect: () -> Unit,
) {
    AddressSection(state.address)

    if (state.pollQuestion.isNullOrBlank()) {
        CreatePollSection(
            question = question, onQuestionChange = onQuestionChange,
            options = options, onOptionsChange = onOptionsChange,
            onCreatePoll = onCreatePoll, busy = busy,
        )
    } else {
        PollResultsSection(
            question = state.pollQuestion ?: "",
            options = state.pollOptions ?: emptyList(),
            tallies = state.tallies ?: listOf(0, 0, 0, 0),
            totalVotes = state.voteCount ?: 0,
            hasVoted = state.hasVoted, pollClosed = state.pollClosed == true,
            onCastVote = onCastVote, onClosePoll = onClosePoll, busy = busy,
        )
    }

    HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)

    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        OutlinedButton(
            onClick = onDeployNew, enabled = !busy,
            modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
        ) { Text(text = "部署新合约") }
        OutlinedButton(
            onClick = onDisconnect, enabled = !busy,
            modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
        ) { Text(text = "断开连接") }
    }

    Text(
        text = "结果将从链上实时更新。",
        style = MaterialTheme.typography.labelSmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        textAlign = TextAlign.Center,
        modifier = Modifier.fillMaxWidth(),
    )
}

@Composable
private fun AddressSection(address: String) {
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(modifier = Modifier.padding(12.dp)) {
            Text(
                text = "合约地址",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            Spacer(Modifier.height(2.dp))
            Text(
                text = address,
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurface,
                maxLines = 1, overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
private fun CreatePollSection(
    question: String, onQuestionChange: (String) -> Unit,
    options: String, onOptionsChange: (String) -> Unit,
    onCreatePoll: () -> Unit, busy: Boolean,
) {
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Text(
                text = "创建投票",
                style = MaterialTheme.typography.titleSmall,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                text = "当前不存在投票。请填写以下信息创建投票。",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            OutlinedTextField(
                value = question, onValueChange = onQuestionChange,
                label = { Text("投票问题") },
                placeholder = { Text("例如:最好的编程语言是什么?") },
                singleLine = true, shape = RoundedCornerShape(8.dp),
                modifier = Modifier.fillMaxWidth(),
            )
            OutlinedTextField(
                value = options, onValueChange = onOptionsChange,
                label = { Text("选项") },
                placeholder = { Text("例如:Rust, TypeScript, Kotlin, Go") },
                supportingText = { Text("逗号分隔,最多4个选项") },
                singleLine = true, shape = RoundedCornerShape(8.dp),
                modifier = Modifier.fillMaxWidth(),
            )
            Button(
                onClick = onCreatePoll,
                enabled = !busy && question.isNotBlank() && options.isNotBlank(),
                modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
            ) { Text(text = "创建投票") }
        }
    }
}

@Composable
private fun PollResultsSection(
    question: String, options: List<String>, tallies: List<Long>,
    totalVotes: Long, hasVoted: Boolean, pollClosed: Boolean,
    onCastVote: (Int) -> Unit, onClosePoll: () -> Unit, busy: Boolean,
) {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Text(
            text = question,
            style = MaterialTheme.typography.headlineSmall,
            fontWeight = FontWeight.Bold,
        )

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            if (pollClosed) {
                Surface(
                    shape = RoundedCornerShape(20.dp),
                    color = MaterialTheme.colorScheme.errorContainer,
                ) {
                    Text(
                        text = "已关闭",
                        modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onErrorContainer,
                    )
                }
            }
            if (hasVoted && !pollClosed) {
                Surface(
                    shape = RoundedCornerShape(20.dp),
                    color = MaterialTheme.colorScheme.primaryContainer,
                ) {
                    Text(
                        text = "已投票",
                        modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onPrimaryContainer,
                    )
                }
            }
            if (totalVotes > 0) {
                Text(
                    text = "$totalVotes${if (totalVotes != 1L) "" else ""}",
                    style = MaterialTheme.typography.labelMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    modifier = Modifier.align(Alignment.CenterVertically),
                )
            }
        }

        val maxTally = tallies.maxOrNull()?.coerceAtLeast(1) ?: 1
        options.forEachIndexed { idx, option ->
            val tally = tallies.getOrElse(idx) { 0L }
            val fraction = if (totalVotes > 0) tally.toFloat() / totalVotes else 0f
            OptionCard(
                label = option, tally = tally, fraction = fraction,
                totalVotes = totalVotes,
                isWinning = tally > 0 && tally == tallies.maxOrNull(),
                canVote = !hasVoted && !pollClosed && !busy,
                onVote = { onCastVote(idx) },
            )
        }

        if (!pollClosed) {
            HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
            Surface(
                shape = RoundedCornerShape(12.dp),
                color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
                modifier = Modifier.fillMaxWidth(),
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                ) {
                    Text(
                        text = "管理员操作",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                    Button(
                        onClick = onClosePoll, enabled = !busy,
                        modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
                        colors = ButtonDefaults.buttonColors(
                            containerColor = MaterialTheme.colorScheme.error,
                            contentColor = MaterialTheme.colorScheme.onError,
                        ),
                    ) { Text(text = "关闭投票") }
                }
            }
        }
    }
}

@Composable
private fun OptionCard(
    label: String, tally: Long, fraction: Float,
    totalVotes: Long, isWinning: Boolean, canVote: Boolean, onVote: () -> Unit,
) {
    val bgColor = when {
        isWinning && tally > 0 -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
        canVote -> MaterialTheme.colorScheme.surface
        else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
    }
    Surface(
        shape = RoundedCornerShape(12.dp),
        color = bgColor,
        tonalElevation = if (canVote) 1.dp else 0.dp,
        modifier = Modifier
            .fillMaxWidth()
            .then(if (canVote) Modifier.clickable(onClick = onVote) else Modifier),
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text(
                    text = label,
                    style = MaterialTheme.typography.bodyLarge,
                    fontWeight = FontWeight.Medium,
                    modifier = Modifier.weight(1f),
                )
                Text(
                    text = if (tally > 0) "$tally" else "0",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold,
                    color = if (isWinning && tally > 0)
                        MaterialTheme.colorScheme.primary
                    else
                        MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }

            LinearProgressIndicator(
                progress = { fraction },
                modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
                color = if (isWinning && tally > 0)
                    MaterialTheme.colorScheme.primary
                else
                    MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
                trackColor = MaterialTheme.colorScheme.surfaceVariant,
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                Text(
                    text = if (totalVotes > 0) "${(fraction * 100).toInt()}%" else "-",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                if (canVote) {
                    Text(
                        text = "点击投票",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.primary,
                    )
                }
                if (isWinning && tally > 0) {
                    Text(
                        text = "领先",
                        style = MaterialTheme.typography.labelSmall,
                        fontWeight = FontWeight.SemiBold,
                        color = MaterialTheme.colorScheme.primary,
                    )
                }
            }
        }
    }
}

8.5) Theme files

8.5) 主题文件

ui/theme/Color.kt
— example palette:
kotlin
package com.kuiralabs.starter.counter.ui.theme

import androidx.compose.ui.graphics.Color

val PrimaryRed = Color(0xFFDC0000)
val DarkRed = Color(0xFF8B0000)
val LightRed = Color(0xFFFF4D4D)
val LightRedBg = Color(0xFFFFDAD4)

val AccentYellow = Color(0xFFFFD700)
val DarkYellow = Color(0xFF9A8200)
val LightYellow = Color(0xFFFFF8D4)

val NearBlack = Color(0xFF0D0D0D)
val DarkSurface = Color(0xFF1A1A1A)
val SurfaceVariant = Color(0xFF2D2D2D)
val OutlineColor = Color(0xFF6B6B6B)
val OnSurfaceVariantColor = Color(0xFFC4C4C4)

val AccentGreen = Color(0xFF00D2A0)
val DarkGreen = Color(0xFF003828)

val White = Color(0xFFF5F5F5)
ui/theme/Type.kt
— Material 3 type scale:
kotlin
package com.kuiralabs.starter.counter.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val VoteTypography = Typography(
    headlineLarge = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp,
    ),
    headlineMedium = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp,
    ),
    headlineSmall = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp,
    ),
    titleLarge = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 24.sp,
    ),
    titleMedium = TextStyle(
        fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp,
    ),
    titleSmall = TextStyle(
        fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp,
    ),
    bodyLarge = TextStyle(
        fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp,
    ),
    bodyMedium = TextStyle(
        fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp,
    ),
    bodySmall = TextStyle(
        fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp,
    ),
    labelLarge = TextStyle(
        fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp,
    ),
    labelMedium = TextStyle(
        fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp,
    ),
    labelSmall = TextStyle(
        fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 14.sp,
    ),
)
ui/theme/Theme.kt
VoteTheme
composable:
kotlin
package com.kuiralabs.starter.counter.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable

private val VoteDarkColorScheme = darkColorScheme(
    primary = PrimaryRed,
    onPrimary = White,
    primaryContainer = DarkRed,
    onPrimaryContainer = LightRedBg,
    secondary = AccentYellow,
    onSecondary = NearBlack,
    secondaryContainer = DarkYellow,
    onSecondaryContainer = LightYellow,
    tertiary = AccentGreen,
    onTertiary = NearBlack,
    tertiaryContainer = DarkGreen,
    onTertiaryContainer = AccentGreen,
    background = NearBlack,
    onBackground = White,
    surface = DarkSurface,
    onSurface = White,
    surfaceVariant = SurfaceVariant,
    onSurfaceVariant = OnSurfaceVariantColor,
    outline = OutlineColor,
    outlineVariant = SurfaceVariant,
    error = Color(0xFFCF6679),
    onError = NearBlack,
    errorContainer = Color(0xFF93000A),
    onErrorContainer = Color(0xFFFFDAD6),
)

@Composable
fun VoteTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {
    MaterialTheme(
        colorScheme = VoteDarkColorScheme,
        typography = VoteTypography,
        content = content,
    )
}
The theme is dark-only. All three files are required — omitting any one breaks the build.
ui/theme/Color.kt
— 示例调色板:
kotlin
package com.kuiralabs.starter.counter.ui.theme

import androidx.compose.ui.graphics.Color

val PrimaryRed = Color(0xFFDC0000)
val DarkRed = Color(0xFF8B0000)
val LightRed = Color(0xFFFF4D4D)
val LightRedBg = Color(0xFFFFDAD4)

val AccentYellow = Color(0xFFFFD700)
val DarkYellow = Color(0xFF9A8200)
val LightYellow = Color(0xFFFFF8D4)

val NearBlack = Color(0xFF0D0D0D)
val DarkSurface = Color(0xFF1A1A1A)
val SurfaceVariant = Color(0xFF2D2D2D)
val OutlineColor = Color(0xFF6B6B6B)
val OnSurfaceVariantColor = Color(0xFFC4C4C4)

val AccentGreen = Color(0xFF00D2A0)
val DarkGreen = Color(0xFF003828)

val White = Color(0xFFF5F5F5)
ui/theme/Type.kt
— Material 3 字体尺度:
kotlin
package com.kuiralabs.starter.counter.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val VoteTypography = Typography(
    headlineLarge = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp,
    ),
    headlineMedium = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp,
    ),
    headlineSmall = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp,
    ),
    titleLarge = TextStyle(
        fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 24.sp,
    ),
    titleMedium = TextStyle(
        fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp,
    ),
    titleSmall = TextStyle(
        fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp,
    ),
    bodyLarge = TextStyle(
        fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp,
    ),
    bodyMedium = TextStyle(
        fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp,
    ),
    bodySmall = TextStyle(
        fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp,
    ),
    labelLarge = TextStyle(
        fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp,
    ),
    labelMedium = TextStyle(
        fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp,
    ),
    labelSmall = TextStyle(
        fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 14.sp,
    ),
)
ui/theme/Theme.kt
VoteTheme
可组合项:
kotlin
package com.kuiralabs.starter.counter.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable

private val VoteDarkColorScheme = darkColorScheme(
    primary = PrimaryRed,
    onPrimary = White,
    primaryContainer = DarkRed,
    onPrimaryContainer = LightRedBg,
    secondary = AccentYellow,
    onSecondary = NearBlack,
    secondaryContainer = DarkYellow,
    onSecondaryContainer = LightYellow,
    tertiary = AccentGreen,
    onTertiary = NearBlack,
    tertiaryContainer = DarkGreen,
    onTertiaryContainer = AccentGreen,
    background = NearBlack,
    onBackground = White,
    surface = DarkSurface,
    onSurface = White,
    surfaceVariant = SurfaceVariant,
    onSurfaceVariant = OnSurfaceVariantColor,
    outline = OutlineColor,
    outlineVariant = SurfaceVariant,
    error = Color(0xFFCF6679),
    onError = NearBlack,
    errorContainer = Color(0xFF93000A),
    onErrorContainer = Color(0xFFFFDAD6),
)

@Composable
fun VoteTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {
    MaterialTheme(
        colorScheme = VoteDarkColorScheme,
        typography = VoteTypography,
        content = content,
    )
}
主题仅支持深色模式。三个文件都是必需的——缺少任何一个都会导致构建失败。

8.6)
MainActivity.kt

8.6)
MainActivity.kt

kotlin
package com.kuiralabs.starter.counter

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.kuiralabs.starter.counter.ui.VotingScreen
import com.kuiralabs.starter.counter.ui.theme.VoteTheme
import com.midnight.kuira.dapp.wallet.WalletAppShell
import com.midnight.kuira.sdk.walletruntime.WalletNotifications
import com.midnight.kuira.sdk.walletruntime.SessionLock
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var sessionLock: SessionLock

    private val notifPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) {}

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (WalletNotifications.shouldRequest(this)) {
            notifPermission.launch(WalletNotifications.PERMISSION)
        }
        setContent {
            WalletAppShell {
                VoteTheme {
                    Surface(modifier = Modifier.fillMaxSize()) {
                        VotingScreen()
                    }
                }
            }
        }
    }

    override fun onUserInteraction() {
        super.onUserInteraction()
        sessionLock.onUserActivity()
    }
}
Why
AppCompatActivity
?
The SDK's
SigilStatusPanel
hosts a biometric prompt, which requires
FragmentActivity
.
AppCompatActivity
is a subclass of
FragmentActivity
;
ComponentActivity
is not.
WalletAppShell
wraps:
  • SessionLockGate
    — shows a lock screen overlay when the session is locked (auto-lock or manual lock)
  • WalletOverlayHost
    — hosts full-screen Send/Receive/Settings overlays in the activity window
WalletNotifications.shouldRequest(this)
checks API 33+
POST_NOTIFICATIONS
permission relevance. If the app needs to show progress notifications for background wallet ops, this should be requested. It's best-effort — denial doesn't break functionality.

kotlin
package com.kuiralabs.starter.counter

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.kuiralabs.starter.counter.ui.VotingScreen
import com.kuiralabs.starter.counter.ui.theme.VoteTheme
import com.midnight.kuira.dapp.wallet.WalletAppShell
import com.midnight.kuira.sdk.walletruntime.WalletNotifications
import com.midnight.kuira.sdk.walletruntime.SessionLock
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var sessionLock: SessionLock

    private val notifPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) {}

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (WalletNotifications.shouldRequest(this)) {
            notifPermission.launch(WalletNotifications.PERMISSION)
        }
        setContent {
            WalletAppShell {
                VoteTheme {
                    Surface(modifier = Modifier.fillMaxSize()) {
                        VotingScreen()
                    }
                }
            }
        }
    }

    override fun onUserInteraction() {
        super.onUserInteraction()
        sessionLock.onUserActivity()
    }
}
为什么使用
AppCompatActivity
SDK的
SigilStatusPanel
包含生物识别提示框,该提示框需要
FragmentActivity
AppCompatActivity
FragmentActivity
的子类;
ComponentActivity
不是。
WalletAppShell
包含:
  • SessionLockGate
    — 会话锁定时(自动锁定或手动锁定)显示锁屏覆盖层
  • WalletOverlayHost
    — 在Activity窗口中托管全屏的发送/接收/设置覆盖层
WalletNotifications.shouldRequest(this)
检查API 33+的
POST_NOTIFICATIONS
权限是否相关。如果应用需要为后台钱包操作显示进度通知,应该请求此权限。这是尽力而为的——拒绝权限不会破坏功能。

9) Failure Modes & Troubleshooting

9) 故障模式与故障排除

9.1) Build failures

9.1) 构建失败

SymptomCauseFix
Could not find method kuiraContract()
io.github.kuiralabs.contract
plugin not applied
Check the plugin ID and version in
app/build.gradle.kts
Cannot access class 'com.midnight.kuira.*'
Kuira dep not resolvedCheck
mavenCentral()
in
settings.gradle.kts
, verify
kuira
version in catalog
[Dagger/MissingBinding] PasskeyConfig
No
PasskeyConfigModule
Create the module (§6.2)
OutOfMemoryError: Metaspace
Not enough JVM heapBump
org.gradle.jvmargs
to
-Xmx4096m
Configuration cache state found but cache is disabled
Changed config cache setting mid-projectDelete
~/.gradle/configuration-cache/
and rebuild
Could not find required SDK platform API 36
SDK platform not installedInstall Android SDK Platform 36
java.lang.RuntimeException: Cannot load contract JS
Asset not syncedRun
./gradlew :app:assembleDebug
to trigger
kuiraContract
sync
IllegalStateException: Contract not compiled at .../managed/voting
kuiraContract.validateKuiraContractSource
failing
Run
npm run compile:voting
in
contract/
症状原因修复
Could not find method kuiraContract()
未应用
io.github.kuiralabs.contract
插件
检查
app/build.gradle.kts
中的插件ID和版本
Cannot access class 'com.midnight.kuira.*'
Kuira依赖未解析检查
settings.gradle.kts
中的
mavenCentral()
,验证版本目录中的
kuira
版本
[Dagger/MissingBinding] PasskeyConfig
缺少
PasskeyConfigModule
创建该模块(第6.2节)
OutOfMemoryError: Metaspace
JVM堆内存不足
org.gradle.jvmargs
调整为
-Xmx4096m
Configuration cache state found but cache is disabled
项目中途更改了配置缓存设置删除
~/.gradle/configuration-cache/
并重新构建
Could not find required SDK platform API 36
未安装SDK平台安装Android SDK Platform 36
java.lang.RuntimeException: Cannot load contract JS
资源未同步运行
./gradlew :app:assembleDebug
触发
kuiraContract
同步
IllegalStateException: Contract not compiled at .../managed/voting
kuiraContract.validateKuiraContractSource
验证失败
contract/
目录下运行
npm run compile:voting

9.2) Runtime failures

9.2) 运行时失败

SymptomCauseFix
"Could not find module '@midnight-ntwrk/compact-runtime'"
Runtime version mismatchCheck
contract-info.json
vs
package.json
(§5.1)
CircuitExecutionException: Only admin can close poll
Wrong witness valueThe hardcoded
ADMIN_SECRET
matches or it doesn't. No recovery from a different device.
CircuitExecutionException: Poll is closed
Already closedWait for next poll — no reopen circuit exists
TypeError: optionIdx_0 is not a function
BigInt patch failedCheck
patch-voting.js
matches the generated JS (§5.2)
SessionLock attach failed
Called from wrong lifecycleCall in
Application.onCreate()
, not
Activity.onCreate()
WalletNotifications.shouldRequest
crash on API < 33
Runtime check not guardedThe SDK handles this internally; if it crashes, you may have minSdk < 30
NullPointerException
on
readLedger
before createPoll
adminKeyHash
is undefined
Handle
VotingLedger.adminKeyHash
being all-zeros or null in UI
App shows "Forge a sigil" but user already has oneSDK state resetClear app data and re-forge, or check logcat for
SigilStateProvider
errors
症状原因修复
"Could not find module '@midnight-ntwrk/compact-runtime'"
运行时版本不匹配检查
contract-info.json
package.json
(第5.1节)
CircuitExecutionException: Only admin can close poll
见证值错误硬编码的
ADMIN_SECRET
要么匹配要么不匹配。不同设备无法恢复。
CircuitExecutionException: Poll is closed
投票已关闭等待下一次投票——没有重新打开的电路
TypeError: optionIdx_0 is not a function
BigInt补丁失败检查
patch-voting.js
是否与生成的JS匹配(第5.2节)
SessionLock attach failed
在错误的生命周期中调用
Application.onCreate()
中调用,而非
Activity.onCreate()
WalletNotifications.shouldRequest
在API < 33时崩溃
未进行运行时检查SDK内部会处理此问题;如果崩溃,可能是minSdk < 30
NullPointerException
在createPoll前调用
readLedger
adminKeyHash
未定义
在UI中处理
VotingLedger.adminKeyHash
为全0或null的情况
应用显示"生成sigil"但用户已拥有sigilSDK状态重置清除应用数据并重新生成,或检查logcat中的
SigilStateProvider
错误

9.3) Network failures

9.3) 网络失败

SymptomCauseFix
java.net.ConnectException: Connection refused
Localnet not runningRun
mn localnet up
javax.net.ssl.SSLHandshakeException
Cleartext blocked on localnetAdd
10.0.2.2
to
network_security_config.xml
(§4.3) or use debug manifest (§4.5)
RP_ID_MISMATCH
Passkey domain changedDelete credential in Google Password Manager, re-forge
INSUFFICIENT_DUST
Wallet not fundedAirdrop NIGHT and register dust (§10.2)
Transaction pending foreverIndexer not syncedWait for
observeLedger()
to emit or check indexer health
症状原因修复
java.net.ConnectException: Connection refused
本地网络未运行运行
mn localnet up
javax.net.ssl.SSLHandshakeException
本地网络明文传输被阻止
10.0.2.2
添加到
network_security_config.xml
(第4.3节)或使用debug清单(第4.5节)
RP_ID_MISMATCH
Passkey域名已更改在Google密码管理器中删除凭据,重新生成
INSUFFICIENT_DUST
钱包未充值领取NIGHT并注册dust(第10.2节)
交易一直处于待处理状态索引器未同步等待
observeLedger()
发出更新或检查索引器健康状态

9.4) Contract compilation failures

9.4) 合约编译失败

SymptomCauseFix
Error: Unknown compiler version +0.31.0
compactc not installed at that version
compact update 0.31.0
Could not find package '@midnight-ntwrk/compact-runtime'
npm deps not installed
cd contract && npm install
Syntax error: expected ';'
Language version mismatchCheck
pragma language_version
matches toolchain
The compiler cannot find module
Import path issueEnsure
contract-info.json
paths are relative
症状原因修复
Error: Unknown compiler version +0.31.0
未安装对应版本的compactc
compact update 0.31.0
Could not find package '@midnight-ntwrk/compact-runtime'
npm依赖未安装
cd contract && npm install
Syntax error: expected ';'
语言版本不匹配检查
pragma language_version
是否与工具链匹配
The compiler cannot find module
导入路径问题确保
contract-info.json
中的路径是相对路径

9.5) Gradle version catalog gotchas

9.5) Gradle版本目录注意事项

  • Kotlin ↔ KSP version coupling: Kotlin 2.3.20 requires KSP 2.3.6. If you upgrade Kotlin to 2.3.21, you MUST also find and upgrade KSP to the matching version. The KSP version format is
    <kotlin-version>-<ksp-release>
    .
  • AppCompat 1.7.x requires fragment-ktx 1.8.x — mixing a newer appcompat with an older fragment causes
    NoSuchMethodError: Fragment.getParentFragmentManager()
    .
  • AGP ↔ Gradle coupling: AGP 8.13 requires Gradle 8.13. If you change the wrapper distribution URL, the
    buildscript
    classpath will fail.
  • Compose BOM and compiler: The
    kotlin-compose
    plugin in Kotlin 2.0+ replaces the old
    composeOptions { kotlinCompilerExtensionVersion }
    block. Do NOT set a compose compiler version manually — the BOM and plugin coordinate automatically.

  • Kotlin ↔ KSP版本耦合: Kotlin 2.3.20需要KSP 2.3.6。如果将Kotlin升级到2.3.21,必须同时找到并升级KSP到匹配版本。KSP版本格式为
    <kotlin-version>-<ksp-release>
  • AppCompat 1.7.x需要fragment-ktx 1.8.x — 新版本appcompat与旧版本fragment混合使用会导致
    NoSuchMethodError: Fragment.getParentFragmentManager()
  • AGP ↔ Gradle耦合: AGP 8.13需要Gradle 8.13。如果更改了包装器分发URL,
    buildscript
    类路径会失败。
  • Compose BOM和编译器: Kotlin 2.0+中的
    kotlin-compose
    插件替代了旧的
    composeOptions { kotlinCompilerExtensionVersion }
    块。不要手动设置compose编译器版本——BOM和插件会自动协调版本。

10) Wallet & Deploy Flow

10) 钱包与部署流程

10.1) Prerequisite: Passkey domain

10.1) 前置条件:Passkey域名

  1. Pick a domain you control (e.g.,
    yourname.github.io
    )
  2. Upload
    assetlinks.json
    to
    https://<domain>/.well-known/
  3. Set
    PASSKEY_RP_ID
    in
    PasskeyConfigModule.kt
  1. 选择一个你控制的域名(如
    yourname.github.io
  2. assetlinks.json
    上传到
    https://<domain>/.well-known/
  3. PasskeyConfigModule.kt
    中设置
    PASSKEY_RP_ID

10.2) Wallet funding (localnet)

10.2) 钱包充值(本地网络)

  1. Open app, tap Forge sigil in the wallet panel
  2. Copy wallet address from the wallet panel
  3. Airdrop NIGHT:
    bash
    mn airdrop 1000 --wallet <addr> --network undeployed
  4. In app, tap Register dust in the wallet panel
  5. Wait ~30 seconds for dust sync
  6. Tap Deploy contract
  1. 打开应用,点击钱包面板中的生成sigil
  2. 从钱包面板复制钱包地址
  3. 领取NIGHT:
    bash
    mn airdrop 1000 --wallet <addr> --network undeployed
  4. 在应用中,点击钱包面板中的注册dust
  5. 等待约30秒完成dust同步
  6. 点击部署合约

10.3) Using the app

10.3) 使用应用

  1. Create poll: Enter question + options (comma-separated), tap "Create poll". Wait for the transaction to confirm.
  2. Cast vote: Tap an option card. The
    ContractCallProgressBar
    shows ZK proving progress. After success, the "You Voted" badge appears and tallies update.
  3. Close poll: Tap "Close poll" (only works on the device that deployed). After close, the "Closed" badge appears and voting is disabled.
  4. Switch network: Use the network selector in
    PanelBar
    . Each network has its own deployed contract address.
  5. Disconnect: Clears the persisted address, returns to deploy screen.

  1. 创建投票: 输入问题和选项(逗号分隔),点击"创建投票"。等待交易确认。
  2. 提交投票: 点击选项卡片。
    ContractCallProgressBar
    会显示ZK证明进度。成功后,会出现"已投票"徽章,票数会更新。
  3. 关闭投票: 点击"关闭投票"(仅部署设备可操作)。关闭后,会出现"已关闭"徽章,投票功能被禁用。
  4. 切换网络: 使用
    PanelBar
    中的网络选择器。每个网络都有自己的已部署合约地址。
  5. 断开连接: 清除持久化地址,返回部署界面。

11) Testing & Verification

11) 测试与验证

Localnet flow

本地网络流程

bash
undefined
bash
undefined

Start localnet (Docker stack)

启动本地网络(Docker栈)

mn localnet up
mn localnet up

Compile contract

编译合约

cd contract && npm install && npm run compile:voting
cd contract && npm install && npm run compile:voting

Install + run

安装并运行

cd .. && ./gradlew :app:installDebug
undefined
cd .. && ./gradlew :app:installDebug
undefined

Verify APK assets

验证APK资源

bash
./gradlew :app:assembleDebug
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep voting
bash
./gradlew :app:assembleDebug
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep voting

Expected:

预期输出:

assets/runtime/voting-contract.js

assets/runtime/voting-contract.js

assets/keys/createPoll.verifier

assets/keys/createPoll.verifier

assets/keys/castVote.verifier

assets/keys/castVote.verifier

assets/keys/closePoll.verifier

assets/keys/closePoll.verifier

assets/keys/createPoll.prover

assets/keys/createPoll.prover

assets/keys/castVote.prover

assets/keys/castVote.prover

assets/keys/closePoll.prover

assets/keys/closePoll.prover

assets/keys/*.bzkir (if bundleWalletKeys is enabled)

assets/keys/*.bzkir (如果启用了bundleWalletKeys)

undefined
undefined

Pre-release check

发布前检查

bash
./gradlew kuiraDoctor
Catches: missing
PasskeyConfig
binding, unhosted
assetlinks.json
, stale contract artifacts, missing assets.

bash
./gradlew kuiraDoctor
检查内容:缺少
PasskeyConfig
绑定、未托管
assetlinks.json
、过期合约产物、缺少资源。

12) Toolchain Bump Procedure

12) 工具链升级流程

When
compactc
, the language version, or
@midnight-ntwrk/compact-runtime
updates:
  1. compact update <new-version>
    and
    compact list
    to confirm
  2. Bump
    engines.compactc
    in
    contract/package.json
  3. Bump
    @midnight-ntwrk/compact-runtime
    in
    contract/package.json
  4. CRITICAL: Update the
    +<version>
    pin in
    compile:voting
    script
  5. Check
    scripts/patch-voting.js
    against newly generated JS — variable names may have changed
  6. rm -rf src/managed/voting && npm run compile:voting
  7. Verify runtime version in
    contract-info.json
    matches
    package.json
  8. ./gradlew :app:assembleDebug
    to trigger asset resync
  9. Run the localnet flow to verify deploy + createPoll + castVote + closePoll all work
  10. ./gradlew kuiraDoctor
  11. Commit regenerated artifacts

compactc
、语言版本或
@midnight-ntwrk/compact-runtime
更新时:
  1. compact update <new-version>
    compact list
    确认版本
  2. contract/package.json
    中升级
    engines.compactc
  3. contract/package.json
    中升级
    @midnight-ntwrk/compact-runtime
  4. 关键: 更新
    compile:voting
    脚本中的
    +<version>
    固定版本
  5. 检查
    scripts/patch-voting.js
    是否与新生成的JS匹配——变量名可能已更改
  6. rm -rf src/managed/voting && npm run compile:voting
  7. 验证
    contract-info.json
    中的运行时版本与
    package.json
    匹配
  8. ./gradlew :app:assembleDebug
    触发资源重新同步
  9. 运行本地网络流程,验证部署、创建投票、提交投票、关闭投票均正常工作
  10. ./gradlew kuiraDoctor
  11. 提交重新生成的产物

13) SDK APIs Used

13) 使用的SDK API

APIPurpose
MidnightSdkProvider
Injected singleton providing
MidnightSdk
+ network selection
MidnightContract.create(config) { }
Build contract handle with name, JS, address, verifier keys, witnesses
.deploy(onProgress)
Deploy contract, returns
DeployResult (.contractAddress)
.call(circuit, vararg args, onProgress)
Call a circuit — circuit
assert
failure throws
CircuitExecutionException
.ledger()
Read current on-chain state, returns typed
MidnightLedger
.observeLedger()
Reactive
Flow<MidnightLedger>
— emits current state then each change
MidnightLedger.getBoolean/getString/getStringOrNull/getUint64/getBytes(n, size)
Typed ledger field accessors
ProvingKeyManager.installCircuitKeysFromAssets()
Stage proving keys from assets for on-device proving
PanelBar(floating = true, network, onNetworkChange)
Drop-in floating sigil + wallet pills
WalletAppShell { }
Root wrapper —
SessionLockGate
+
WalletOverlayHost
ContractCallProgressBar(stage, accent)
Built-in progress for deploy/call stages
WalletForegroundService.attach(this)
Background wallet ops survive app backgrounding
SessionLock.attach(this)
/
.onUserActivity()
App-wide auto-lock + idle-timer reset
WalletNotifications.shouldRequest(this)
/
PERMISSION
POST_NOTIFICATIONS
gating for API 33+
MidnightSdk.coinPublicKey
The wallet's public key needed for write handles
WitnessResult(null, bytes)
Witness provider returning a static byte array

API用途
MidnightSdkProvider
注入的单例,提供
MidnightSdk
和网络选择功能
MidnightContract.create(config) { }
使用名称、JS、地址、验证密钥、见证构建合约句柄
.deploy(onProgress)
部署合约,返回
DeployResult (.contractAddress)
.call(circuit, vararg args, onProgress)
调用电路——电路
assert
失败会抛出
CircuitExecutionException
.ledger()
读取当前链上状态,返回类型化的
MidnightLedger
.observeLedger()
响应式
Flow<MidnightLedger>
——先发出当前状态,再发出每次变化
MidnightLedger.getBoolean/getString/getStringOrNull/getUint64/getBytes(n, size)
类型化账本字段访问器
ProvingKeyManager.installCircuitKeysFromAssets()
从资源中加载证明密钥,用于设备端证明
PanelBar(floating = true, network, onNetworkChange)
开箱即用的浮动sigil + 钱包芯片
WalletAppShell { }
根包装器——包含
SessionLockGate
+
WalletOverlayHost
ContractCallProgressBar(stage, accent)
内置的部署/调用进度条
WalletForegroundService.attach(this)
后台钱包操作在应用后台时仍能运行
SessionLock.attach(this)
/
.onUserActivity()
应用级自动锁定 + 空闲计时器重置
WalletNotifications.shouldRequest(this)
/
PERMISSION
API 33+的
POST_NOTIFICATIONS
权限控制
MidnightSdk.coinPublicKey
写句柄需要的钱包公钥
WitnessResult(null, bytes)
返回静态字节数组的见证提供者

14) References

14) 参考资料