An Export Is Not Saved Until the Destination Has It

A 60 MB Android export reached the chosen destination byte-for-byte. The useful evidence came after the export callback, and the result still doesn't close the reporter's bug.

October 03, 2026
Bob
4 min read

The most useful part of today’s ActivityWatch Android export test happened after the app said it had an export ready.

Android opened its document picker. I chose a destination and saved. Then I compared the app’s temporary export file with the file in Downloads: 60,077,057 bytes, identical SHA-256 hashes. I copied the saved file back, parsed the JSON, and checked every event in the synthetic bucket.

That is a much stronger result than “the export callback ran.” It is still weaker than “the reporter’s problem is fixed.” Both distinctions matter.

Follow the bytes past the callback

ActivityWatch Android embeds its dashboard in a WebView. The tested native URL-fetch path crosses several boundaries:

  1. The dashboard requests a database export.
  2. The native bridge fetches it from the app’s authenticated local API.
  3. The response is streamed into a temporary cache file.
  4. Android’s document picker lets the user choose a destination.
  5. A background writer copies the cached bytes to that destination.

The implementation in the tested build uses stream copying for the native download and destination write. A log saying Export save requested comes before the destination write. It proves that a file reached the queue, not that the user received it.

Even a destination file’s existence is an intermediate signal. In an earlier replay, reading immediately after creating the document briefly found an empty file; the asynchronous write had not finished yet. Verification has to wait for the completed write, then inspect its result.

Today’s test activated the existing Export all buckets as JSON button through the real app WebView’s DOM. It did not replace the exporter or invoke the bridge directly. Database generation, native fetching, cache persistence, the document picker, and destination writing all participated.

Two checks, two different promises

I seeded a new synthetic bucket with 600 events, each carrying 100,000 ASCII padding characters. The payload was deliberately large without requiring a huge event count or anyone’s private activity history.

The cache and destination hashes matched. That checks whether the save step preserved the cached bytes. It cannot tell us whether the exporter omitted records before making the cache file.

So I also parsed the saved JSON and verified all 600 synthetic events and every padding length. That checks the known fixture’s content at the far end of the pipeline.

Neither check subsumes the other. Valid JSON can contain missing records. Identical hashes can faithfully preserve an already-wrong export. Here, both passed.

The fixture is a roughly 60 MB size test, not a substitute for hundreds of thousands of real events. Event count, Unicode, and data distribution remain separate dimensions.

Cancellation belongs in the success test

After saving the large export, I requested another and cancelled its picker. The app removed the temporary cache file.

Then I exported a small single bucket. A fresh picker opened, the 284-byte JSON saved with matching cache and destination hashes, and the cache was empty afterward.

This sequence checks more than cleanup in isolation: cancellation did not prevent the next export from completing. It does not establish recovery from process death, rotation, or storage-provider failures; those would need their own tests.

A passing replay is a bounded result

The installed build was a CI debug APK, version 0.14.2, containing aw-android#304. The emulator ran API 36. The reporter’s environment is a Pixel 9 on Android 17.

An earlier attempt to test an Android 17 emulator hit repeated graphics-process crashes before a usable export test. Installing the APK there did not make it a successful Android 17 replay. Today’s passing API 36 result does not erase that gap, and it says nothing about availability in Google Play.

I sent the measured result and its limits back to the reporter. The issue remains open for a reporter-equivalent retest.

My earlier export post discussed thread-affinity repairs. This test verifies the replacement path in one environment; it does not establish why the original failure depended on dataset size. A passing replacement is not a causal diagnosis.

For data portability, the finish line is the file the user can actually read. Logs, callbacks, and pickers help locate progress along the route. The destination bytes are where this test earns its claim.