android-share-payload-binder-limit
Find and fix Android share/export actions that pass large payloads through Intent extras and will throw TransactionTooLargeException once real user data accumulates — measuring the payload at realisti
SKILL.mdskills/managed/android-share-payload-binder-limit/SKILL.md
GitHubShare payloads and the binder limit
Failure mode: Intent.EXTRA_TEXT (or any large extra) carries a payload that
grows with user data. A binder transaction is capped near 1 MB for the whole
transaction, so the action throws TransactionTooLargeException only for users
with the most history — and passes every test on a fresh install.
This bites hardest on export / "share my data" actions, which is what people reach for when something has already gone wrong.
1. Find the sites
rtk grep -rn "EXTRA_TEXT\|putExtra(Intent\." app/src/main/kotlin --include=*.kt
A share helper with no file I/O is the tell. When hunting main-thread disk work with StrictMode, a share path that never touches disk looks innocent — it is the opposite defect.
2. Measure the payload at realistic scale, don't estimate
Seed years of data into an isolated database and measure the real string:
var chars = 0
repo.exportJson().also { chars = it.length }
assertTrue(
"export is %.2f MB at %d sessions".format(chars / 1048576.0, SESSIONS),
chars in 1 until 4_000_000,
)
Report the number in the failure message and keep it in the test as a comment: the point is that the ceiling stays visible to whoever edits that code next. (Real example: 1,000 sessions → 0.87 MB, i.e. already at the limit.)
3. Fix by staging a file, not by trimming the payload
private suspend fun shareExport(context: Context, title: String, fileName: String, text: String) {
val uri = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, fileName) // one name, overwritten: cache is not an archive
file.writeText(text)
FileProvider.getUriForFile(context, "${context.packageName}.exports", file)
}
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "application/json"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
},
title,
),
)
}
Manifest + res/xml/file_paths.xml — scope the provider to the staging
directory only, so the database, its backups and any crash journal stay
unreachable:
<provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.exports"
android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<paths><cache-path name="exports" path="exports/" /></paths>
Route every share through it. Two sharing paths with different failure modes is how one of them rots.
4. Prove it on device
The write is invisible in the UI, so check the staged file directly:
adb shell am start -n <pkg>/.MainActivity
# navigate and tap the export control (match its REAL label from source)
adb shell run-as <pkg> ls -l /data/data/<pkg>/cache/exports/
adb shell logcat -d -b crash # expect empty
Confirm three things: the chooser appeared, the file exists with non-zero size, and the crash buffer is empty.
Traps
- The button label is uppercased by a custom composable.
label = "Export Archive"renders asEXPORT ARCHIVE; a MaterialButtondoes not uppercase. Match the rendered text, or the tap silently misses. - The control is below the fold. Scroll before concluding it is absent, and take tap coordinates from a dump made after the scroll.
- The instrumented gate uninstalls the app. A device sweep straight after
connectedAndroidTestdrives the launcher; checkpm path <pkg>first. startActivityneeds the main thread; do the file write inDispatchers.IOand let the chooser launch on the caller.