Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A collector is three pieces:

### Design constraints

Collectors observe a source and emit events. They do not write files, change study state, start activities, schedule prompts, export, or request permissions. This is not a rule imposed on collector authors so much as a consequence of the module graph: a `collector:*` module depends only on `core:collector-api` and `core:study-definition`, so storage, the runtime, and the protocol layer are not on its classpath.
Collectors observe a source and emit events. They do not write files, change study state, start activities, schedule interventions, render surveys, export, or request permissions. This is not a rule imposed on collector authors so much as a consequence of the module graph: a `collector:*` module depends only on `core:collector-api` and `core:study-definition`, so storage, the runtime, and the protocol layer are not on its classpath.

That boundary is what keeps a new data source cheap to add and cheap to review. It also means the answer to "how do I persist this myself?" is that you do not — everything goes through the `EventSink` in your `CollectorContext`, which is what makes sequence numbers contiguous and monotone, quota accounting correct, and a bundle able to declare the exact window it carries.

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Standing up a mobile sensing study normally means writing an Android app, gettin
## How a study works

1. **Generate your keys.** One Ed25519 pair to sign study configurations, one HPKE pair to decrypt exports. `researcher-tools` does both.
2. **Write the study.** A JSON file naming the collectors you want, their parameters, the duration, prompts, the on-device storage quota (8 MiB to 8 GiB), consent text, your export public key, and a `signer` block carrying the key ID and public half of your signing key.
2. **Write the study.** A strict v1 JSON file naming collectors, reusable surveys, scheduled interventions, anonymous or assigned-code identity mode, duration, storage quota, consent text, and signing/export public keys.
3. **Sign it.** `researcher-tools sign` produces a `.adccfg` file. Because the signing public key travels inside the signed bytes, any build of the app can verify it.
4. **Distribute.** Participants install the app and import your `.adccfg`. Setup is five steps, one screen each — the study details, what each enabled collector records and does not record, the consent text with the signer's key fingerprint, the Android access your collectors need, and the start button — and collection begins only when they press it.
5. **Collect.** Events are written to encrypted on-device storage. Participants can pause, resume, finish early, or withdraw.
Expand Down Expand Up @@ -49,8 +49,10 @@ Studies collect from people's personal phones, so the platform is built to suppo

- **Encrypted on the device.** Each study's events and metadata are encrypted with a per-study AES-256-GCM key from the Android Keystore, marked non-exportable, in 4 MiB event segments under the app's no-backup storage, up to the quota the configuration set. An event is appended once and never rewritten; the only thing that removes a segment is confirmed delivery to the study's endpoint, and then only under storage pressure.
- **Signed, tamper-evident studies.** A configuration is Ed25519-signed and strictly validated: canonical encoding, exact schema, known collectors, validity window, minimum app version. Verification failures are fail-closed — an unverifiable configuration collects nothing. A signature proves the configuration is unchanged since it was signed; it does not prove who wrote it unless the build pins that signer, and the consent screen states which of the two applies.
- **Durable interventions and native surveys.** Notification actions can use one-time, recurring, or daily-local triggers. Their occurrences survive retries and reboot without duplication. Native surveys support short text, integer scales, single choice, and multiple choice; only a confirmed, complete submission enters the encrypted event stream.
- **Separated participant identities.** Every import gets a fresh random instance UUID. A configuration may additionally carry an opaque researcher-assigned code; both appear inside encrypted exports, while upload headers expose only the instance UUID used for routing and de-duplication.
- **Encrypted, participant-directed export.** Getting data to the research team is an export the participant performs and directs, encrypted with a fresh key per export and wrapped to your HPKE public key. The app never holds your private key.
- **Scheduled upload, when the study asks for it.** A configuration may name an HTTPS endpoint, an interval, and whether metered networks are allowed. The app then delivers the same encrypted bundles on that schedule, over Wi-Fi unless the study allows otherwise, so the endpoint stores ciphertext only your private key opens. The endpoint host, the cadence, and the network condition are shown on the consent screen before the participant accepts — and the app honours the interval you signed rather than clamping it to a platform floor. Each run sends everything outstanding up to a 16 MiB size budget and records exactly how far it got, so a backlog drains across runs without ever re-sending or skipping an event. Upload is part of the study rather than a separate switch. A configuration whose `upload` block is empty sends nothing, and collection never depends on an upload succeeding — a study whose endpoint is unreachable keeps recording, manual export still works, and the participant sees a fixed failure code such as `UPLOAD_TIMEOUT` rather than a raw error. Finishing or withdrawing stops collection, its prompts, and its deadline, but delivery continues until the undelivered tail has reached you. Confirmed delivery is also what lets a device reclaim local space once its quota runs high; undelivered events are never released to make room, and the participant is told when earlier ones were.
- **Scheduled upload, when the study asks for it.** A configuration may name an HTTPS endpoint, interval, and metered-network policy. The endpoint host, cadence, and network condition are shown before consent. Each run sends outstanding events up to a 16 MiB plaintext budget and records its exact boundary. Finishing or withdrawing cancels future interventions and the deadline, while delivery continues until the undelivered tail arrives. Undelivered events are never reclaimed to make room.
- **Participant control over the lifecycle.** Collection starts only on an explicit action and can be paused, finished, or withdrawn. Pausing takes a monotonic boundary, so delayed callbacks cannot leak post-pause data into the dataset.
- **Storage failures stop collection.** Quota exhaustion or a write failure fail-closes the study to `PAUSED` rather than silently dropping events, so a dataset is complete over the window it declares or absent.

Expand Down
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ dependencies {
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)

testImplementation(libs.junit4)

androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.runner)
Expand Down
2 changes: 1 addition & 1 deletion app/src/debug/res/raw/demo_study_envelope.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
QURDQ0ZHMDEAEAAACN0AQGRlbW8tc2lnbmVyLTIwMjZ7InNjaGVtYV92ZXJzaW9uIjoxLCJleHBlcmltZW50X2lkIjoibW9kdWxhci1zZW5zaW5nLWRlbW8iLCJjb25maWd1cmF0aW9uX2lkIjoiZGVtby1jb25maWctMjAyNiIsImlzc3VlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwiZXhwaXJlc19hdCI6IjIwMzUtMDEtMDFUMDA6MDA6MDBaIiwibWluaW11bV9hcHBfdmVyc2lvbiI6MSwidGl0bGUiOiJNb2R1bGFyIHNlbnNpbmcgZGVtb25zdHJhdGlvbiIsInJlc2VhcmNoZXIiOnsibmFtZSI6IkFuZHJvaWQgRGF0YSBDb2xsZWN0b3IgbWFpbnRhaW5lcnMiLCJjb250YWN0IjoicmVzZWFyY2hAZXhhbXBsZS5pbnZhbGlkIn0sInB1cnBvc2UiOiJWZXJpZnkgdGhlIGNvbXBsZXRlIG9uLWRldmljZSBjb2xsZWN0aW9uLCBwYXVzZSwgZXhwb3J0LCBhbmQgcmVzZWFyY2hlci1kZWNyeXB0aW9uIGxvb3AuIiwiZHVyYXRpb25faG91cnMiOjI0LCJjb25zZW50Ijp7ImRvY3VtZW50X3ZlcnNpb24iOiJkZW1vLTEiLCJzdW1tYXJ5IjoiVGhpcyBkZW1vbnN0cmF0aW9uIGNhbiBjb2xsZWN0IHByZWNpc2UgbG9jYXRpb24sIG1vdGlvbiwgbmV0d29yayBzdGF0ZSwgYWdncmVnYXRlIFdpLUZpL21vYmlsZSB1c2FnZSwgYXBwIGFuZCBzY3JlZW4gdXNhZ2UgZXZlbnRzLCB0aGlzIGFwcCdzIGxpZmVjeWNsZSwgYW5kIHRvdWNoIGR5bmFtaWNzIG1hZGUgaW5zaWRlIHRoZSBvcHRpb25hbCByZXNlYXJjaCBrZXlib2FyZC4gSXQgbmV2ZXIgcmVjb3JkcyBrZXlib2FyZCB0ZXh0LiBEYXRhIHN0YXlzIGVuY3J5cHRlZCBvbiB0aGlzIGRldmljZSB1bnRpbCB5b3UgY2hvb3NlIEV4cG9ydC4gWW91IGNhbiBwYXVzZSwgd2l0aGRyYXcsIGV4cG9ydCByZXBlYXRlZGx5LCBvciBkZWxldGUgbG9jYWwgZGF0YS4ifSwiY29sbGVjdG9ycyI6W3siaWQiOiJhcHBfbGlmZWN5Y2xlLnYxIiwicmVxdWlyZWQiOnRydWUsImNvbmZpZyI6e319LHsiaWQiOiJhY2NlbGVyb21ldGVyLnYxIiwicmVxdWlyZWQiOnRydWUsImNvbmZpZyI6eyJzYW1wbGluZ19wZXJpb2RfdXMiOjEwMDAwMCwibWF4aW11bV9yZXBvcnRfbGF0ZW5jeV91cyI6MTAwMDAwMH19LHsiaWQiOiJuZXR3b3JrX3N0YXRlLnYxIiwicmVxdWlyZWQiOnRydWUsImNvbmZpZyI6eyJpbmNsdWRlX2JhbmR3aWR0aF9lc3RpbWF0ZXMiOnRydWV9fSx7ImlkIjoibmV0d29ya191c2FnZS52MSIsInJlcXVpcmVkIjpmYWxzZSwiY29uZmlnIjp7InRyYW5zcG9ydHMiOlsibW9iaWxlIiwid2lmaSJdLCJwb2xsX2ludGVydmFsX21pbnV0ZXMiOjV9fSx7ImlkIjoidXNhZ2VfZXZlbnRzLnYxIiwicmVxdWlyZWQiOmZhbHNlLCJjb25maWciOnsicG9sbF9pbnRlcnZhbF9taW51dGVzIjoxNX19LHsiaWQiOiJsb2NhdGlvbi52MSIsInJlcXVpcmVkIjpmYWxzZSwiY29uZmlnIjp7ImludGVydmFsX21pbGxpcyI6MTAwMDAsIm1pbmltdW1faW50ZXJ2YWxfbWlsbGlzIjo1MDAwLCJtYXhpbXVtX2JhdGNoX2RlbGF5X21pbGxpcyI6MzAwMDAsIm1pbmltdW1fZGlzcGxhY2VtZW50X21ldGVycyI6NS4wLCJwcmlvcml0eSI6IkJBTEFOQ0VEIn19LHsiaWQiOiJrZXlib2FyZF90b3VjaC52MSIsInJlcXVpcmVkIjpmYWxzZSwiY29uZmlnIjp7InRyYWplY3Rvcnlfc2FtcGxpbmdfaHoiOjYwfX1dLCJwcm9tcHRzIjpbeyJpZCI6ImRlbW8tY2hlY2staW4iLCJkZWxheV9taW51dGVzIjo2MCwibWVzc2FnZSI6IlBsZWFzZSBjaGVjayB0aGF0IHRoZSBzdHVkeSBpcyBzdGlsbCBydW5uaW5nIGFzIGV4cGVjdGVkLiJ9XSwic3RvcmFnZSI6eyJtYXhpbXVtX2xvY2FsX2J5dGVzIjoxNjc3NzIxNn0sInNpZ25lciI6eyJrZXlfaWQiOiJkZW1vLXNpZ25lci0yMDI2IiwicHVibGljX2tleSI6Ik1Db3dCUVlESzJWd0F5RUFzUlNhVHBabVRTQkw3ZU42blMvSEJzTm1MTThuMWhkUm1JdDF2dExac0MwPSJ9LCJleHBvcnQiOnsicmVzZWFyY2hlcl9rZXlfaWQiOiJkZW1vLWhwa2UtMjAyNiIsInRpbmtfaHBrZV9wdWJsaWNfa2V5c2V0Ijp7InByaW1hcnlLZXlJZCI6MjE4OTkyNzI3LCJrZXkiOlt7ImtleURhdGEiOnsidHlwZVVybCI6InR5cGUuZ29vZ2xlYXBpcy5jb20vZ29vZ2xlLmNyeXB0by50aW5rLkhwa2VQdWJsaWNLZXkiLCJ2YWx1ZSI6IkVnWUlBUkFCR0FJYUlCcHlRM3c0ZkZ4OVhnRVV4NWt5elphSVBYTHE3YVlVNlJKK3k5K3JHTkVBIiwia2V5TWF0ZXJpYWxUeXBlIjoiQVNZTU1FVFJJQ19QVUJMSUMifSwic3RhdHVzIjoiRU5BQkxFRCIsImtleUlkIjoyMTg5OTI3MjcsIm91dHB1dFByZWZpeFR5cGUiOiJUSU5LIn1dfX0sInVwbG9hZCI6e319qNwoReutzhOLUp/zBskXBkDTpjAWugkmT6f35dcu0vBfMCmQ8WSLKhv2L41ttYC/dnERe0sxLe6fwOxqEtihAQ==
QURDQ0ZHMDEAEAAACyYAQGRlbW8tc2lnbmVyLTIwMjZ7InNjaGVtYV92ZXJzaW9uIjoxLCJleHBlcmltZW50X2lkIjoibW9kdWxhci1zZW5zaW5nLWRlbW8iLCJjb25maWd1cmF0aW9uX2lkIjoiZGVtby1jb25maWctMjAyNiIsImFzc2lnbmVkX3BhcnRpY2lwYW50X2lkIjpudWxsLCJpc3N1ZWRfYXQiOiIyMDI2LTAxLTAxVDAwOjAwOjAwWiIsImV4cGlyZXNfYXQiOiIyMDM1LTAxLTAxVDAwOjAwOjAwWiIsIm1pbmltdW1fYXBwX3ZlcnNpb24iOjEsInRpdGxlIjoiTW9kdWxhciBzZW5zaW5nIGRlbW9uc3RyYXRpb24iLCJyZXNlYXJjaGVyIjp7Im5hbWUiOiJBbmRyb2lkIERhdGEgQ29sbGVjdG9yIG1haW50YWluZXJzIiwiY29udGFjdCI6InJlc2VhcmNoQGV4YW1wbGUuaW52YWxpZCJ9LCJwdXJwb3NlIjoiVmVyaWZ5IHRoZSBjb21wbGV0ZSBvbi1kZXZpY2UgY29sbGVjdGlvbiwgcGF1c2UsIGV4cG9ydCwgYW5kIHJlc2VhcmNoZXItZGVjcnlwdGlvbiBsb29wLiIsImR1cmF0aW9uX2hvdXJzIjoyNCwiY29uc2VudCI6eyJkb2N1bWVudF92ZXJzaW9uIjoiZGVtby0xIiwic3VtbWFyeSI6IlRoaXMgZGVtb25zdHJhdGlvbiBjYW4gY29sbGVjdCBwcmVjaXNlIGxvY2F0aW9uLCBtb3Rpb24sIG5ldHdvcmsgc3RhdGUsIGFnZ3JlZ2F0ZSBXaS1GaS9tb2JpbGUgdXNhZ2UsIGFwcCBhbmQgc2NyZWVuIHVzYWdlIGV2ZW50cywgdGhpcyBhcHAncyBsaWZlY3ljbGUsIGFuZCB0b3VjaCBkeW5hbWljcyBtYWRlIGluc2lkZSB0aGUgb3B0aW9uYWwgcmVzZWFyY2gga2V5Ym9hcmQuIEl0IG5ldmVyIHJlY29yZHMga2V5Ym9hcmQgdGV4dC4gRGF0YSBzdGF5cyBlbmNyeXB0ZWQgb24gdGhpcyBkZXZpY2UgdW50aWwgeW91IGNob29zZSBFeHBvcnQuIFlvdSBjYW4gcGF1c2UsIHdpdGhkcmF3LCBleHBvcnQgcmVwZWF0ZWRseSwgb3IgZGVsZXRlIGxvY2FsIGRhdGEuIn0sImNvbGxlY3RvcnMiOlt7ImlkIjoiYXBwX2xpZmVjeWNsZS52MSIsInJlcXVpcmVkIjp0cnVlLCJjb25maWciOnt9fSx7ImlkIjoiYWNjZWxlcm9tZXRlci52MSIsInJlcXVpcmVkIjp0cnVlLCJjb25maWciOnsic2FtcGxpbmdfcGVyaW9kX3VzIjoxMDAwMDAsIm1heGltdW1fcmVwb3J0X2xhdGVuY3lfdXMiOjEwMDAwMDB9fSx7ImlkIjoibmV0d29ya19zdGF0ZS52MSIsInJlcXVpcmVkIjp0cnVlLCJjb25maWciOnsiaW5jbHVkZV9iYW5kd2lkdGhfZXN0aW1hdGVzIjp0cnVlfX0seyJpZCI6Im5ldHdvcmtfdXNhZ2UudjEiLCJyZXF1aXJlZCI6ZmFsc2UsImNvbmZpZyI6eyJ0cmFuc3BvcnRzIjpbIm1vYmlsZSIsIndpZmkiXSwicG9sbF9pbnRlcnZhbF9taW51dGVzIjo1fX0seyJpZCI6InVzYWdlX2V2ZW50cy52MSIsInJlcXVpcmVkIjpmYWxzZSwiY29uZmlnIjp7InBvbGxfaW50ZXJ2YWxfbWludXRlcyI6MTV9fSx7ImlkIjoibG9jYXRpb24udjEiLCJyZXF1aXJlZCI6ZmFsc2UsImNvbmZpZyI6eyJpbnRlcnZhbF9taWxsaXMiOjEwMDAwLCJtaW5pbXVtX2ludGVydmFsX21pbGxpcyI6NTAwMCwibWF4aW11bV9iYXRjaF9kZWxheV9taWxsaXMiOjMwMDAwLCJtaW5pbXVtX2Rpc3BsYWNlbWVudF9tZXRlcnMiOjUuMCwicHJpb3JpdHkiOiJCQUxBTkNFRCJ9fSx7ImlkIjoia2V5Ym9hcmRfdG91Y2gudjEiLCJyZXF1aXJlZCI6ZmFsc2UsImNvbmZpZyI6eyJ0cmFqZWN0b3J5X3NhbXBsaW5nX2h6Ijo2MH19XSwic3VydmV5cyI6W3siaWQiOiJkZW1vLXN1cnZleSIsInRpdGxlIjp7ImRlZmF1bHQiOiJTdHVkeSBjaGVjay1pbiIsInRyYW5zbGF0aW9ucyI6eyJ6aC1UVyI6IueglOeptueiuuiqjSJ9fSwiZGVzY3JpcHRpb24iOnsiZGVmYXVsdCI6IlRlbGwgdXMgaG93IHRoZSBzdHVkeSBpcyBnb2luZy4iLCJ0cmFuc2xhdGlvbnMiOnt9fSwicXVlc3Rpb25zIjpbeyJ0eXBlIjoic2hvcnRfdGV4dCIsImlkIjoic3RhdHVzLW5vdGUiLCJwcm9tcHQiOnsiZGVmYXVsdCI6IkhvdyBpcyBpdCBnb2luZz8iLCJ0cmFuc2xhdGlvbnMiOnt9fSwicmVxdWlyZWQiOmZhbHNlLCJtYXhpbXVtX2xlbmd0aCI6NTAwfV19XSwiaW50ZXJ2ZW50aW9ucyI6W3siaWQiOiJkZW1vLWNoZWNrLWluIiwiYWN0aW9uIjp7InR5cGUiOiJzdXJ2ZXkiLCJub3RpZmljYXRpb25fdGl0bGUiOiJTdHVkeSBjaGVjay1pbiIsIm5vdGlmaWNhdGlvbl9tZXNzYWdlIjoiUGxlYXNlIGNvbXBsZXRlIHRoZSBzdHVkeSBjaGVjay1pbi4iLCJzdXJ2ZXlfaWQiOiJkZW1vLXN1cnZleSJ9LCJ0cmlnZ2VycyI6W3siaWQiOiJhZnRlci1vbmUtaG91ciIsInNjaGVkdWxlIjp7InR5cGUiOiJvbmVfdGltZSIsIm9mZnNldF9taW51dGVzIjo2MCwiY2xvY2siOiJBQ1RJVkVfUlVOTklOR19USU1FIn0sImF2YWlsYWJpbGl0eV9taW51dGVzIjoxNDQwfV19XSwic3RvcmFnZSI6eyJtYXhpbXVtX2xvY2FsX2J5dGVzIjoxNjc3NzIxNn0sInNpZ25lciI6eyJrZXlfaWQiOiJkZW1vLXNpZ25lci0yMDI2IiwicHVibGljX2tleSI6Ik1Db3dCUVlESzJWd0F5RUFzUlNhVHBabVRTQkw3ZU42blMvSEJzTm1MTThuMWhkUm1JdDF2dExac0MwPSJ9LCJleHBvcnQiOnsicmVzZWFyY2hlcl9rZXlfaWQiOiJkZW1vLWhwa2UtMjAyNiIsInRpbmtfaHBrZV9wdWJsaWNfa2V5c2V0Ijp7InByaW1hcnlLZXlJZCI6MjE4OTkyNzI3LCJrZXkiOlt7ImtleURhdGEiOnsidHlwZVVybCI6InR5cGUuZ29vZ2xlYXBpcy5jb20vZ29vZ2xlLmNyeXB0by50aW5rLkhwa2VQdWJsaWNLZXkiLCJ2YWx1ZSI6IkVnWUlBUkFCR0FJYUlCcHlRM3c0ZkZ4OVhnRVV4NWt5elphSVBYTHE3YVlVNlJKK3k5K3JHTkVBIiwia2V5TWF0ZXJpYWxUeXBlIjoiQVNZTU1FVFJJQ19QVUJMSUMifSwic3RhdHVzIjoiRU5BQkxFRCIsImtleUlkIjoyMTg5OTI3MjcsIm91dHB1dFByZWZpeFR5cGUiOiJUSU5LIn1dfX0sInVwbG9hZCI6e319eQt/MbwbgKiep/lySOSuib/oEqqRxyR9LuR7lffn30Tz7G7jz1ue7a3mV+1zqhtEbdYAoLnhleIZRa0GmcvcCA==
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SurveyActivity"
android:exported="false" />
<service
android:name=".CollectionService"
android:exported="false"
Expand All @@ -51,6 +54,8 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,20 @@ import kotlinx.coroutines.launch

class BootRecoveryReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
if (intent.action !in RECOVERY_ACTIONS) return
val pending = goAsync()
val application = context.applicationContext as CollectorApplication
application.applicationScope.launch {
try {
application.session.snapshot.first { it.initialized }
application.session.rescheduleInterventions()
} finally {
pending.finish()
}
}
}

private companion object {
val RECOVERY_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class CollectorApplication : Application() {
// The delivery chain is one-time work, so it has no platform-side repetition to fall
// back on. Re-establishing it here covers a link lost to a crash or a force stop.
session.snapshot.value.configuration?.let(workScheduler::reschedulePendingWork)
session.rescheduleInterventions()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ private fun ConsentPanel(study: StudyUiState.ActiveStudy, actions: StudyUiAction
// prose, so they describe what the app will actually do even if the summary leaves it out.
// Their wording is deliberately complete.
PublisherDisclosure(study.configuration, study.signerAnchored)
IdentityDisclosure(study.configuration.assignedParticipantId)
UploadDisclosure(study.configuration.upload)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
Expand All @@ -441,6 +442,27 @@ private fun ConsentPanel(study: StudyUiState.ActiveStudy, actions: StudyUiAction
}
}

@Composable
private fun IdentityDisclosure(assignedParticipantId: String?) {
Disclosure(
mark = { GlyphIcon(Glyph.PERSON, MaterialTheme.colorScheme.primary, 16.dp) },
title = stringResource(
if (assignedParticipantId == null) R.string.consent_identity_anonymous_title
else R.string.consent_identity_personalized_title,
),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (assignedParticipantId == null) {
Text(stringResource(R.string.consent_identity_anonymous_body))
} else {
Text(stringResource(R.string.consent_identity_assigned_code, assignedParticipantId), fontWeight = FontWeight.Bold)
Text(stringResource(R.string.consent_identity_personalized_body))
}
Text(stringResource(R.string.consent_identity_instance_body))
}
}
}

/**
* A block the app asserts itself, set apart from the researcher's prose above it by ground rather
* than by being a separate card: it belongs to this step, and the step shows one thing.
Expand Down Expand Up @@ -727,6 +749,10 @@ private fun StudyDetails(study: StudyUiState.ActiveStudy) {
if (expanded) {
HorizontalDivider()
DetailRow(stringResource(R.string.details_configuration), configuration.configurationId)
DetailRow(stringResource(R.string.details_instance_id), study.metadata.participantInstanceId)
configuration.assignedParticipantId?.let {
DetailRow(stringResource(R.string.details_assigned_id), it)
}
DetailRow(stringResource(R.string.details_consent_document), configuration.consentDocumentVersion)
DetailRow(stringResource(R.string.details_signature), configuration.signer.fingerprint)
study.lastExport?.let {
Expand Down
Loading