🚧 Proceed with caution: Bugstr Android is proof of concept stage, and has not been reviewed by a professional developer 🚧
Bugstr packages the crash reporting flow that Amethyst uses to prompt users to share stack traces with developers over expiring (NIP-17) direct messages. Bugstr includes Quartz, or Android SDK via Amethyst. It is designed to be re-used by other Nostr apps—or any Android app that wants an opt-in crash reporter that keeps the user in control of what is sent.
- Android/Kotlin ✅
- TypeScript - https://github.com/alltheseas/Bugstr-TS
- Flutter/Dart - 🚧 Planned
Bugstr ships four building blocks:
BugstrCrashReportCachestores crash stack traces on disk. It defaults to one slot; setmaxReportsor a customslotKeyfor multi-slot rotation. All disk I/O is suspend and runs onDispatchers.IO.BugstrCrashHandlerinstalls anUncaughtExceptionHandler, accepts an attachments provider, and blocks the crashing thread with a bounded timeout while flushing to disk.BugstrCrashPromptis a Jetpack Compose dialog that surfaces all cached reports (newest first) and lets the user send, keep, or dismiss them.BugstrAnrWatcher(optional) can write a synthetic report when the main thread stalls.
class MyApp : Application() {
private val bugstrCache by lazy { BugstrCrashReportCache(this, maxReports = 3) }
private val bugstrHandler by lazy {
BugstrCrashHandler(
cache = bugstrCache,
assembler = BugstrReportAssembler(
appName = "My App",
appVersionName = BuildConfig.VERSION_NAME,
buildVariant = BuildConfig.FLAVOR.ifBlank { "release" },
),
attachmentsProvider = { mapOf("recent logs" to fetchRecentLogs()) },
writeTimeoutMs = 1_000,
)
}
override fun onCreate() {
super.onCreate()
bugstrHandler.installAsDefault()
}
}This mirrors the way Amethyst keeps the default handler and only writes non-OOM crashes to disk.
If you also want ANR coverage, wire up BugstrAnrWatcher:
private val anrWatcher by lazy {
BugstrAnrWatcher(
cache = bugstrCache,
assembler = BugstrReportAssembler(
appName = "My App",
appVersionName = BuildConfig.VERSION_NAME,
buildVariant = BuildConfig.FLAVOR.ifBlank { "release" },
)
)
}
override fun onCreate() {
super.onCreate()
bugstrHandler.installAsDefault()
anrWatcher.start()
}In any Compose screen you can drop BugstrCrashPrompt and wire up the onSendReport callback to your own navigation or DM composer. Bugstr will load and delete the cached crash reports on the first composition.
@Composable
fun CrashReportEntryPoint(
accountViewModel: AccountViewModel,
nav: INav,
) {
BugstrCrashPrompt(
cache = Amethyst.instance.crashReportCache,
developerName = "Amethyst",
onSendReport = { stack ->
nav.nav {
routeToMessage(
user = LocalCache.getOrCreateUser(AMETHYST_DEV_PUBKEY),
draftMessage = stack,
accountViewModel = accountViewModel,
expiresDays = 30, // <- enables expiring NIP-17 DMs
)
}
},
)
}In this example the expiresDays flag is what turns the DM composer into a NIP-17 ephemeral message, so the crash report vanishes for everyone after 30 days.
BugstrCrashPrompt exposes optional parameters (titleText, descriptionText, sendButtonText, dismissButtonText, retryButtonText, loadingText) so you can plug in your own localized strings or keep Bugstr’s defaults. The default copy now reminds users that stack traces might contain personal data.
- Bugstr avoids reading or sending anything automatically. Users stay in control and can inspect/edit the crash report before sharing.
- You can store multiple crashes by setting
maxReportsor providing a slot key when writing. The prompt iterates through everything it finds. BugstrCrashPromptoffers a “Keep for later” button that rewrites the remaining reports to disk instead of discarding them.BugstrReportAssemblerrecurses through the entireThrowablecause chain, trims overly large traces (default 200k characters), and intentionally omitsBuild.HOST/Build.USERto keep ROM build metadata out of the report. TunemaxStackCharactersif needed.- Attachments are supported via the crash handler's
attachmentsProvider. They render under their own headings and are truncated for safety.
The bugstr-nostr-crypto module provides standalone NIP-17/44/59 gift wrap building:
val builder = Nip17PayloadBuilder(giftWrapper)
val wraps = builder.buildGiftWraps(
Nip17Request(
senderPubKey = myPubKey,
senderPrivateKeyHex = myPrivKey,
recipients = listOf(Nip17Recipient(pubKeyHex = devPubKey)),
plaintext = crashReport,
expirationSeconds = 30 * 24 * 60 * 60, // 30 days
)
)
// Sign and publish wraps.map { it.giftWrap } to relaysThe implementation follows:
- NIP-17 - Private Direct Messages (kind 14 rumors)
- NIP-44 - Versioned Encryption (v2)
- NIP-59 - Gift Wrap (rumor → seal → gift wrap)
- NIP-40 - Expiration Timestamp
Important: Rumors include id (computed) and sig: "" (empty string) per spec. Some clients reject messages without these fields.
See AGENTS.md for contributor guidelines.