关于 android-example-voting
android-example-voting 是使用 Kuira Android SDK 构建 Midnight Network 投票 dApp 的技能,面向 Android 开发者。它涵盖环境配置、合约部署、投票和响应式结果,并提供固定版本与故障排查说明。
主要功能
- 01Android 投票 dApp 构建指南
- 02创建 Passkey-DID 和钱包
- 03部署 Compact 合约
- 04四选一的设备端 ZK 投票
- 05管理员控制投票流程
- 06响应式账本结果更新
适用场景
- 01构建 Midnight Android 投票应用
- 02创建仅限管理员密钥操作的投票
- 03在设备端进行隐私保护投票
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill android-example-voting安装后在 Claude Code 中输入/android-example-voting确认已加载。
详细介绍
Kuira Voting dApp
此 skill 会使用 Kuira Android SDK 为 Midnight Network 生成一个可运行的投票 dApp。 以下每个代码片段都直接摘自可正常运行的 starter-android 代码库。如果构建失败,请阅读 Failure Modes 部分——其中介绍了最常见的三种问题。
主要参考资料:
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— 官方 starterhttps://central.sonatype.com/namespace/io.github.kuiralabs— Maven Central
此项目会构建什么
| 能力 | 方式 |
|---|---|
| 创建 Forge sigil | PanelBar — 一次生物识别即可创建 passkey-DID + 钱包 |
| 部署合约 | 将 Compact 合约部署到 Midnight |
| 创建投票 | 通过 SHA-256 管理员密钥匹配,仅限组织者 |
| 投票 | 选择 4 个选项之一,在设备上通过 ZK 证明 |
| 关闭投票 | 只有管理员密钥持有者才能关闭 |
| 实时结果 | observeLedger() 以响应式方式串流状态变更 |
固定版本
以下是可正常运行的代码库中使用的实际版本。偏离这些版本 会导致依赖解析失败(参见 §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 语言 pragma | >= 0.22 |
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
警告:local.properties 必须存在且包含 sdk.dir=...,否则 AGP android 块无法解析 compileSdk。Android Studio 会创建该文件;在 CI 中,必须手动创建或使用环境变量。
2) 前置条件
2.1) 工具链
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
2.2) Android 设置
- Android Studio Ladybug+
- Android 13+(API 33+)设备/模拟器,并已设置屏幕锁定
- 用于 passkey
rpId的域名(或使用 GitHub Pages) - Google Password Manager(GMS Core 23.40.13+)
2.3) Gradle wrapper
gradle/wrapper/gradle-wrapper.properties 中的 wrapper 必须指定 Gradle 8.13(与 AGP 8.13.2 匹配):
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 配置
3.1) gradle.properties — 必需
如果没有此文件,Gradle 会使用默认值,导致构建极其缓慢或配置缓存失效:
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 依赖会解析到旧版 support 库,并在运行时因
ClassNotFoundException 而失败。
**故障模式:**如果没有 org.gradle.jvmargs=-Xmx2048m,KSP 和 Hilt
一起使用时,大型项目可能会发生 OOM。如果看到
OutOfMemoryError: Metaspace,请将其提高到 -Xmx4096m。
**故障模式:**如果 app/build.gradle.kts 在配置时访问项目文件
(例如 file("../contract")),org.gradle.configuration-cache=true 会导致失败。下面的 Copy 任务通过在 doFirst 中使用 rootProject.file() 避免了此问题;但如果你添加的插件会在求值期间读取文件,请禁用配置缓存。
3.2) settings.gradle.kts
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) 根目录 build.gradle.kts
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 contract 插件(io.github.kuiralabs.contract)和
localnet 插件(io.github.kuiralabs.localnet)直接在
app/build.gradle.kts 中按 ID 和版本应用,而不是通过版本
目录应用。它们不会出现在此处。
3.4) gradle/libs.versions.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" }
版本固定规则:
- AGP 和 Gradle wrapper 的主版本号和次版本号必须一致: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
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)
}
插件详情:
io.github.kuiralabs.contract — 处理 kuiraContract { ... }
代码块。它会:
- 注册
validateKuiraContractSource— 如果../contract/src/managed/voting不存在,则显示清晰的错误消息并使构建失败 - 注册
syncContractAssets— 将 JS 和密钥复制到src/main/assets/{runtime,keys}/ - 将
syncContractAssets关联到preBuild bundleWalletKeys.set(true)标志会使插件在 bundle 任务期间将.bzkirbundle 文件复制到 assets
io.github.kuiralabs.localnet — 在实体设备上执行 installDebug 期间,
自动为 localnet 端口运行 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 资源
4.1) app/src/main/res/values/strings.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
<?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
<?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"。
两种方式在 localnet 上都有效,但此方式不会在调试构建中向整个互联网开放
明文流量。如果使用调试清单方式(参见 §4.5),它只会在 debug
构建类型中启用,因此同样安全。
4.4) 主 AndroidManifest.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,允许 localnet 通过明文访问 localhost/模拟器主机,
而无需全局启用明文流量。
4.5) 调试清单(network_security_config 的替代方案)
app/src/debug/AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>
合并规则:调试清单仅会在调试构建中合并到主清单。 这种方式比安全配置更简单,但会向所有主机开放明文流量,而不仅限于 localnet。
失败情况: 如果同时使用 networkSecurityConfig 和
usesCleartextTraffic="true",则以安全配置为准。如果
配置未包含正确的域名,明文流量仍会失败。
5) 合约
5.1) contract/package.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"
}
}
故障模式:运行时版本不匹配。 编译后的 JS 文件
src/managed/voting/contract/index.js 包含硬编码的运行时
版本要求。如果 package.json 中的
@midnight-ntwrk/compact-runtime 版本与编译器在编译
时写入的版本不匹配,Android 应用在加载
合约时会抛出 JS 运行时错误。
症状: "Could not find module '@midnight-ntwrk/compact-runtime'"
或 JS 引擎抛出晦涩的 TypeError。
修复方法: 检查托管输出中的 contract-info.json:
cat src/managed/voting/compiler/contract-info.json
# Look for "runtime-version" — must match package.json's dep
如果二者不同,请将 package.json 中的
@midnight-ntwrk/compact-runtime 升级到匹配的版本,或使用正确的工具链
版本重新编译。
compact compile 命令中的 +0.31.0 版本锁定至关重要。
如果没有它,该命令会使用 compact 工具链默认的编译器,
这可能与合约上次编译时使用的运行时版本不匹配。务必锁定版本。
5.2) contract/scripts/patch-voting.js
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 到 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 circuit 的第一个参数。此名称由编译器
生成,可能会发生变化。重新编译后务必验证补丁是否生效。
5.3) contract/src/voting.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。 这意味着它本身不会产生 公共输出,只会在其他 circuit 中使用。pure关键字表示该 circuit 不写入账本,也没有公共输入;它是一个辅助函数。 -
语言版本范围为
>= 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) 编译合约
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 中没有预期的变量名。请检查并更新正则表达式。
编译后,验证运行时版本:
cat src/managed/voting/compiler/contract-info.json
# Confirm runtime-version matches @midnight-ntwrk/compact-runtime in package.json
6) 应用与 DI
6.1) KuiraStarterApp.kt
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
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 声明为必需绑定。如果使用该 SDK 的应用没有提供此配置,Dagger 将在编译时失败,并提示 [Dagger/MissingBinding] PasskeyConfig is injected at ...。这是有意为之——SDK 无法了解你的领域信息。
Passkey 域名设置:
- 将
PASSKEY_RP_ID设置为你控制的域名 - 在
https://<rpId>/.well-known/assetlinks.json托管assetlinks.json,其中包含应用的applicationId和签名 SHA-256:./gradlew signingReport | grep SHA-256[{ "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 创建,但现在使用的是另一个。唯一的解决方法是从 Google Password Manager 中删除该凭据,然后重新创建。
7) 数据层
7.1) data/VotingContract.kt
这是核心合约封装。每个细节都很重要。
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 与 class 的区别——这是一个单例,而非按实例创建的类。ADMIN_SECRET 被硬编码为 ByteArray(32) { 0x42 }(所有字节均为 0x42)。同一个 APK 的每次部署都会使用相同的管理员密钥。任何反编译 APK 的人都能获得每个已部署投票的管理员密钥。
WitnessResult(null, ADMIN_SECRET):第一个参数是 privateState: Any?——这里为 null,因为不需要私有状态。第二个参数是 witness 字节数组。WitnessResult 会将两者封装为 witness 函数返回的单个对象。
MidnightContract.create(sdk.config) { ... } DSL 构建器:
name:用作查找合约 JS 资源的键contractJs:合约 JS 文件的InputStreamaddress:链上地址(部署时为 null)coinPublicKey:写入操作(签名)所需circuitVerifierKeys:电路名称 → 验证器密钥字节的映射witness(name) { ... }:注册 witness 提供程序
资源路径约定: 合约 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/ 是否存在且包含正确文件。
失败模式: 如果尚未设置 adminKeyHash(即在 createPoll 之前),调用 getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32) 会抛出异常。合约构造函数不会初始化 adminKeyHash,因此它的值可能全为零或未定义。实际合约会在 createPoll 中设置该值。UI 必须处理 adminKeyHash 缺失的情况。
7.2) data/ContractAddressStore.kt
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 层
8.1) ui/VotingUiState.kt
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 是一个本地布尔值——在 castVote() 成功后,
ViewModel 会将它设为 true。它不会在进程终止后保留。
ViewModel 被销毁时(例如,Activity 因内存压力被终止),
它会重置为 false。此代码库中没有持久化的“已投票”标志。
8.2) ui/VotingViewModel.kt
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与applyLedger: 当 SDK 或网络发生变化时 (例如,钱包解锁、网络切换),会调用recomputeState。 它会重置为初始状态。当链上账本更新时(通过observeLedger()或手动 调用readLedger()),会调用applyLedger。它会保留 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
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) 会渲染一个浮动的 sigil 小组件(左侧)
和钱包小组件(右侧)。这是 SDK 推荐的组合方式——sigil 小组件用于
forge/create/import,钱包小组件则显示余额,并提供发送/接收/粉尘注册功能。
WindowInsets.safeDrawing 可确保内容避开显示屏缺口
和系统栏。
8.4) ui/VotingCard.kt
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,
)
}
}
}
}
}
8.5) 主题文件
ui/theme/Color.kt — 示例调色板:
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 字体比例:
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 可组合项:
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
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) 故障模式与故障排查
9.1) 构建失败
| 症状 | 原因 | 修复方法 |
|---|---|---|
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) 运行时失败
| 症状 | 原因 | 修复方法 |
|---|---|---|
"Could not find module '@midnight-ntwrk/compact-runtime'" | 运行时版本不匹配 | 检查 contract-info.json 与 package.json 是否一致(§5.1) |
CircuitExecutionException: Only admin can close poll | witness 值错误 | 硬编码的 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() 中调用 |
API < 33 时 WalletNotifications.shouldRequest 崩溃 | 未防护运行时检查 | SDK 会在内部处理此情况;如果发生崩溃,你可能使用了低于 30 的 minSdk |
在 createPoll 之前调用 readLedger 时出现 NullPointerException | adminKeyHash 未定义 | 在 UI 中处理 VotingLedger.adminKeyHash 为全零或 null 的情况 |
| 应用显示“锻造 sigil”,但用户已有 sigil | SDK 状态已重置 | 清除应用数据并重新锻造,或检查 logcat 中是否有 SigilStateProvider 错误 |
9.3) 网络故障
| 症状 | 原因 | 修复方法 |
|---|---|---|
java.net.ConnectException: Connection refused | Localnet 未运行 | 运行 mn localnet up |
javax.net.ssl.SSLHandshakeException | Localnet 上的明文流量被阻止 | 将 10.0.2.2 添加到 network_security_config.xml(§4.3),或使用调试清单(§4.5) |
RP_ID_MISMATCH | Passkey 域名已更改 | 在 Google Password Manager 中删除凭据,然后重新锻造 |
INSUFFICIENT_DUST | 钱包未注资 | 空投 NIGHT 并注册 dust(§10.2) |
| 交易一直处于待处理状态 | Indexer 尚未同步 | 等待 observeLedger() 发出数据,或检查 Indexer 的运行状况 |
9.4) 合约编译失败
| 症状 | 原因 | 修复方法 |
|---|---|---|
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 版本目录注意事项
-
**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。如果 更改 wrapper distribution URL,
buildscriptclasspath 将会失败。 -
**Compose BOM 和编译器:**Kotlin 2.0+ 中的
kotlin-compose插件取代了旧的composeOptions { kotlinCompilerExtensionVersion }代码块。不要手动设置 Compose 编译器版本——BOM 和插件会自动协调版本。
10) 钱包与部署流程
10.1) 前提条件:Passkey 域名
- 选择一个你控制的域名(例如,
yourname.github.io) - 将
assetlinks.json上传到https://<domain>/.well-known/ - 在
PasskeyConfigModule.kt中设置PASSKEY_RP_ID
10.2) 为钱包充值(localnet)
- 打开应用,在钱包面板中点击 Forge sigil
- 从钱包面板复制钱包地址
- 空投 NIGHT:
mn airdrop 1000 --wallet <addr> --network undeployed - 在应用中,点击钱包面板中的 Register dust
- 等待约 30 秒以完成 dust 同步
- 点击 Deploy contract
10.3) 使用应用
- **创建投票:**输入问题和选项(用逗号分隔),点击 “Create poll”。等待交易确认。
- **投票:**点击一个选项卡片。
ContractCallProgressBar会显示 ZK 证明进度。成功后会显示“You Voted”徽章, 并更新计票结果。 - **关闭投票:**点击“Close poll”(仅限部署该投票的设备)。 关闭后会显示“Closed”徽章,并禁用投票。
- **切换网络:**使用
PanelBar中的网络选择器。每个 网络都有各自部署的合约地址。 - **断开连接:**清除已持久化的地址,并返回部署界面。
11) 测试与验证
Localnet 流程
# Start localnet (Docker stack)
mn localnet up
# Compile contract
cd contract && npm install && npm run compile:voting
# Install + run
cd .. && ./gradlew :app:installDebug
验证 APK 资源
./gradlew :app:assembleDebug
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep voting
# Expected:
# assets/runtime/voting-contract.js
# assets/keys/createPoll.verifier
# assets/keys/castVote.verifier
# assets/keys/closePoll.verifier
# assets/keys/createPoll.prover
# assets/keys/castVote.prover
# assets/keys/closePoll.prover
# assets/keys/*.bzkir (if bundleWalletKeys is enabled)
发布前检查
./gradlew kuiraDoctor
可发现:缺少 PasskeyConfig 绑定、assetlinks.json 未托管、
合约产物过时、缺少资源。
12) 工具链升级流程
当 compactc、语言版本或 @midnight-ntwrk/compact-runtime
更新时:
- 执行
compact update <new-version>和compact list进行确认 - 升级
contract/package.json中的engines.compactc - 升级
contract/package.json中的@midnight-ntwrk/compact-runtime - **重要:**更新
compile:voting脚本中的+<version>固定版本 - 检查
scripts/patch-voting.js是否适用于新生成的 JS—— 变量名可能已更改 rm -rf src/managed/voting && npm run compile:voting- 确认
contract-info.json中的运行时版本与package.json一致 - 执行
./gradlew :app:assembleDebug以触发资源重新同步 - 运行 localnet 流程,验证 deploy、createPoll、castVote 和 closePoll 均正常工作
- 执行
./gradlew kuiraDoctor - 提交重新生成的产物
13)使用的 SDK API
| 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) | 返回静态字节数组的见证器提供程序 |