From 90de9093bc93a7f2b6b57de4aeb1b96b324dfdf4 Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 03:02:21 +0800 Subject: [PATCH 1/8] Report when a pause started and how long it has lasted --- .../cool/linc/particeps/CollectorDashboard.kt | 55 +++++++++++++++---- app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt b/app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt index d7fa222..5effce6 100644 --- a/app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt +++ b/app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt @@ -1,5 +1,6 @@ package cool.linc.particeps +import android.text.format.DateUtils import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -77,6 +78,7 @@ object UiTags { const val WITHDRAW = "withdraw" const val EXPORT = "export" const val EVENT_COUNT = "event_count" + const val PAUSED_SINCE = "paused_since" } data class StudyUiActions( @@ -307,20 +309,53 @@ private fun StatusLine(state: ExperimentState, metadata: StudyMetadata?) { value = System.currentTimeMillis() } } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(10.dp).background(stateTint(state), CircleShape)) - Text( - stringResource(state.labelRes()), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.testTag(UiTags.STATE), - ) - started?.let { - Text(elapsedLabel((ended ?: now) - it), color = MaterialTheme.colorScheme.onSurfaceVariant) + // A pause is the one state a participant can leave the study in by accident, so it reports both + // halves: when it started, and how long ago that was. The elapsed figure beside the state name + // is the study's own age and keeps running through a pause, which is why it cannot carry this. + val pausedAt = state.takeIf { it == ExperimentState.PAUSED }?.let { + metadata?.transitions?.lastOrNull { transition -> transition.to == ExperimentState.PAUSED } + ?.time?.wallTimeUtcMillis + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(10.dp).background(stateTint(state), CircleShape)) + Text( + stringResource(state.labelRes()), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(UiTags.STATE), + ) + started?.let { + Text(elapsedLabel((ended ?: now) - it), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + pausedAt?.let { + Text( + stringResource(R.string.status_paused_since, wallClockLabel(it), elapsedLabel(now - it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(UiTags.PAUSED_SINCE), + ) } } } +/** + * Android's own date/time rendering, so the participant sees their locale and their 12/24-hour + * setting rather than a format this app invented. The date is always shown: a pause that reads + * "2:32" is indistinguishable from one three days old, and that is exactly the case the line exists + * to catch. + */ +@Composable +private fun wallClockLabel(millis: Long): String { + val context = LocalContext.current + return DateUtils.formatDateTime( + context, + millis, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_ALL, + ) +} + @Composable private fun elapsedLabel(millis: Long): String { val minutes = (millis.coerceAtLeast(0) / 60_000L).toInt() diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4094591..0f04187 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -22,6 +22,7 @@ 準備中 %1$d 分 %1$d 小時 %2$d 分 + 自 %1$s 暫停,已經過 %2$s %1$d 天 %2$d 小時 研究 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff352e9..5f97929 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,8 @@ Starting up %1$dm %1$dh %2$dm + + Paused since %1$s, %2$s ago %1$dd %2$dh From 101fa45d746da0d9b3298d1218bf11d5ec6b2d91 Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 03:17:57 +0800 Subject: [PATCH 2/8] Remind the participant daily that a study is running or paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pause is the one state a participant can leave a study in by accident. Nothing on the phone changes, no notification is showing, and a study meant to run for a fortnight quietly records nothing until someone thinks to open the app. The running case is not padding either: a study that collects for weeks should keep saying so, because consent nobody is reminded of is consent in name only. One notification a day, replacing yesterday's rather than stacking, on its own low-importance channel. Daily is long enough that anything noisier would be a reason to uninstall, which would end a study far more effectively than a missed reminder would. It reports state and nothing else — no counts, no collector names, no study content — because a notification is readable on a lock screen by whoever is holding the phone. The paused text names when the pause started, since a reminder that only said "Paused" leaves no way to tell five minutes from a fortnight. Periodic rather than the self-renewing chain used for delivery: a day is far above WorkManager's fifteen-minute floor, so nothing is silently clamped, and the platform re-establishes periodic work across reboots. Scheduled when collection starts and re-established on session init, so a study already under way before this existed also gets it; deliberately not cancelled on pause, and retired with the rest of the collection work when the study ends. --- .../cool/linc/particeps/DailyStatusWorker.kt | 112 ++++++++++++++++++ .../platform/AndroidStudyPlatform.kt | 34 ++++++ app/src/main/res/values-zh-rTW/strings.xml | 4 + app/src/main/res/values/strings.xml | 5 + 4 files changed, 155 insertions(+) create mode 100644 app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt diff --git a/app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt b/app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt new file mode 100644 index 0000000..17f7015 --- /dev/null +++ b/app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt @@ -0,0 +1,112 @@ +package cool.linc.particeps + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.text.format.DateUtils +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import cool.linc.particeps.core.model.ExperimentState +import kotlinx.coroutines.flow.first + +/** + * One notification a day saying whether the study is still collecting, or still paused. + * + * The paused half is the reason this exists. A pause is the one state a participant can leave a + * study in by accident: nothing on the phone changes, no notification is showing, and a study that + * was meant to run for a fortnight quietly records nothing. The running half is not padding either + * — a study that collects for weeks should keep saying so rather than becoming invisible, because + * consent that nobody is reminded of is consent in name only. + * + * It reports state and nothing else. No counts, no collector names, no study content: a + * notification is readable on a lock screen by whoever is holding the phone. + */ +class DailyStatusWorker( + context: Context, + parameters: WorkerParameters, +) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result { + val application = applicationContext as CollectorApplication + val snapshot = application.session.snapshot.first { it.initialized } + val metadata = snapshot.runtime.metadata + val title = snapshot.configuration?.title + val state = metadata?.state + if (title == null || (state != ExperimentState.RUNNING && state != ExperimentState.PAUSED)) { + // Finished, withdrawn, deleted, or never started. Nothing to remind anyone about, and + // the periodic request outlives the study unless it retires itself here. + return Result.success() + } + if (applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + // Not a retry: the next daily run is soon enough, and retrying would spend the + // participant's battery re-checking a permission only they can grant. + return Result.success() + } + + val text = when (state) { + ExperimentState.PAUSED -> { + val pausedAt = metadata.transitions + .lastOrNull { it.to == ExperimentState.PAUSED } + ?.time + ?.wallTimeUtcMillis + if (pausedAt == null) { + applicationContext.getString(R.string.daily_paused_unknown) + } else { + applicationContext.getString( + R.string.daily_paused_since, + DateUtils.formatDateTime( + applicationContext, + pausedAt, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_ALL, + ), + ) + } + } + else -> applicationContext.getString(R.string.daily_running) + } + + val manager = applicationContext.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.daily_channel), + // Low: this arrives every day for as long as the study runs. Anything that makes a + // sound daily for a fortnight is a reason to uninstall the app, which would end the + // study far more effectively than a missed reminder. + NotificationManager.IMPORTANCE_LOW, + ), + ) + manager.notify( + NOTIFICATION_TAG, + 0, + android.app.Notification.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(text) + .setStyle(android.app.Notification.BigTextStyle().bigText(text)) + .setContentIntent( + PendingIntent.getActivity( + applicationContext, + 0, + Intent(applicationContext, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + .setAutoCancel(true) + .build(), + ) + return Result.success() + } + + companion object { + /** One tag, so today's reminder replaces yesterday's rather than stacking up. */ + const val NOTIFICATION_TAG = "daily-status" + private const val CHANNEL_ID = "research-daily-status-v1" + } +} diff --git a/app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt b/app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt index 60b3d36..ccb4c61 100644 --- a/app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt +++ b/app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt @@ -11,13 +11,16 @@ import android.net.Uri import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import cool.linc.particeps.CollectionService +import cool.linc.particeps.DailyStatusWorker import cool.linc.particeps.ExperimentDeadlineWorker import cool.linc.particeps.MainActivity import cool.linc.particeps.R @@ -77,6 +80,29 @@ class AndroidStudyWorkScheduler( ExistingWorkPolicy.REPLACE, ) } + scheduleDailyStatus() + } + + /** + * The daily reminder, which runs for as long as the study is either collecting or paused. + * + * Periodic rather than a self-renewing chain, unlike delivery: a day is far above WorkManager's + * fifteen-minute floor, so nothing is silently clamped, and periodic work is re-established by + * the platform across reboots without this app having to remember to do it. KEEP so that + * re-entering a study — a resume, a process restart — does not push the next reminder a full + * day away each time. + * + * Deliberately not cancelled on pause. A paused study is exactly the case the reminder exists + * for; [cancelCollectionWork] retires it when the study actually ends. + */ + private fun scheduleDailyStatus() { + workManager.enqueueUniquePeriodicWork( + DAILY_STATUS_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setInitialDelay(1, TimeUnit.DAYS) + .build(), + ) } override fun replaceInterventionWork( @@ -184,6 +210,9 @@ class AndroidStudyWorkScheduler( * is left alone rather than having its delay reset on every app start. */ fun reschedulePendingWork(configuration: StudyConfiguration) { + // Also covers a study that was already under way before the reminder existed, and one + // whose periodic work a force stop cleared. + scheduleDailyStatus() configuration.upload?.let { scheduleUpload( configuration.experimentId, @@ -207,6 +236,10 @@ class AndroidStudyWorkScheduler( override fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) { cancelInterventionWork(experimentId, occurrenceIds) workManager.cancelUniqueWork(deadlineWorkName(experimentId)) + // Finished or withdrawn: the reminder has nothing left to remind anyone of, and today's + // notification would otherwise sit in the shade after the study it describes has ended. + workManager.cancelUniqueWork(DAILY_STATUS_WORK_NAME) + notificationManager.cancel(DailyStatusWorker.NOTIFICATION_TAG, 0) } override fun cancel(experimentId: String) { @@ -215,6 +248,7 @@ class AndroidStudyWorkScheduler( } private fun deadlineWorkName(experimentId: String) = "particeps-deadline-$experimentId" + private val DAILY_STATUS_WORK_NAME = "particeps-daily-status" private fun uploadTag(experimentId: String) = "particeps-upload-$experimentId" companion object { fun uploadWorkName(experimentId: String, configurationId: String) = diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0f04187..aa5bc36 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -5,6 +5,10 @@ --> 研究活動 + 每日研究提醒 + 仍在收集中。開啟 App 可以暫停、匯出或提前結束。 + 自 %1$s 暫停中。在你重新開始之前不會收集任何資料。 + 目前暫停中。在你重新開始之前不會收集任何資料。 問卷 正在開啟問卷… 必填 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f97929..312242f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,11 @@ --> Particeps Research activities + Daily study reminder + Still collecting. Open the app to pause, export, or finish early. + + Paused since %1$s. Nothing is being collected until you resume. + Paused. Nothing is being collected until you resume. Survey Opening survey… Required From deb77c88515cbf3e505a1ad0542689204e87c84d Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 03:32:17 +0800 Subject: [PATCH 3/8] Move the application namespace to cool.jacoblin.particeps The previous namespace carried a personal handle that the product does not use anywhere else, and the release certificate's subject still named the pre-rename product. Neither can be corrected later: an applicationId is how Android identifies an installed application, and a certificate is signed over its own subject, so changing either issues something a device treats as a different app. Doing both now costs nothing, because every tag published so far is a pre-1.0 release candidate and Developer Verification has not been registered against any of them. After a released build reaches a participant it would cost a great deal, so this is the last time. The audit that pins the fresh-install boundary now knows about two dead namespaces rather than one, and refuses a reference to either. The release guide, the README, and invariant 8 no longer claim the signing key was deliberately preserved, which was true of the first rename and is not true now. --- README.md | 22 ++-- app/build.gradle.kts | 4 +- .../AndroidConfigurationImportTest.kt | 6 +- .../particeps/CoreFlowTest.kt | 6 +- .../particeps/P2CollectorEmulatorTest.kt | 44 ++++---- .../{linc => jacoblin}/particeps/DemoStudy.kt | 2 +- .../{linc => jacoblin}/particeps/AppLocale.kt | 2 +- .../particeps/BootRecoveryReceiver.kt | 4 +- .../particeps/CollectionService.kt | 4 +- .../particeps/CollectorApplication.kt | 76 ++++++------- .../particeps/CollectorDashboard.kt | 16 +-- .../particeps/CollectorGlyphs.kt | 2 +- .../particeps/CollectorSummary.kt | 30 ++--- .../particeps/DailyStatusWorker.kt | 4 +- .../particeps/ExperimentDeadlineWorker.kt | 6 +- .../particeps/MainActivity.kt | 10 +- .../particeps/StudyViewModel.kt | 22 ++-- .../particeps/SurveyActivity.kt | 24 ++-- .../particeps/UploadWorker.kt | 6 +- .../platform/AndroidResearchClocks.kt | 6 +- .../platform/AndroidStudyPlatform.kt | 38 +++---- .../particeps/platform/FileUploadOutbox.kt | 6 +- .../platform/JoinArtifactDownloader.kt | 6 +- .../particeps/platform/OkHttpStudyUploader.kt | 24 ++-- .../{linc => jacoblin}/particeps/DemoStudy.kt | 2 +- .../particeps/CollectorSummaryTest.kt | 2 +- .../particeps/DemoStudyAssetTest.kt | 4 +- .../particeps/SurveySubmissionStateTest.kt | 6 +- .../platform/FileUploadOutboxTest.kt | 6 +- .../platform/InterventionWorkPolicyTest.kt | 16 +-- .../platform/JoinArtifactDownloaderTest.kt | 6 +- .../platform/OkHttpStudyUploaderTest.kt | 40 +++---- .../particeps/platform/UploadIdentityTest.kt | 18 +-- assurance/collector-policy.json | 16 +-- collector/accelerometer/build.gradle.kts | 2 +- .../accelerometer/AccelerometerCollector.kt | 26 ++--- collector/ambient-light/build.gradle.kts | 2 +- .../ambientlight/AmbientLightCollector.kt | 32 +++--- .../ambientlight/AmbientLightCollectorTest.kt | 12 +- collector/app-lifecycle/build.gradle.kts | 2 +- .../applifecycle/AppLifecycleCollector.kt | 28 ++--- collector/battery-state/build.gradle.kts | 2 +- .../batterystate/BatteryStateCollector.kt | 38 +++---- .../batterystate/BatteryStateCollectorTest.kt | 12 +- collector/gyroscope/build.gradle.kts | 2 +- .../collector/gyroscope/GyroscopeCollector.kt | 28 ++--- .../gyroscope/GyroscopeCollectorTest.kt | 8 +- collector/keyboard-ime/build.gradle.kts | 2 +- .../keyboardime/ImeObservationBridge.kt | 4 +- .../keyboardime/KeyboardTouchCollector.kt | 30 ++--- .../keyboardime/ResearchInputMethodService.kt | 2 +- .../keyboardime/ResearchKeyboardView.kt | 2 +- .../main/res/xml/research_input_method.xml | 2 +- .../keyboardime/ImeObservationBridgeTest.kt | 2 +- collector/location/build.gradle.kts | 2 +- .../collector/location/LocationCollector.kt | 38 +++---- collector/network-state/build.gradle.kts | 2 +- .../networkstate/NetworkStateCollector.kt | 34 +++--- collector/network-usage/build.gradle.kts | 2 +- .../networkusage/NetworkUsageCollector.kt | 32 +++--- collector/proximity/build.gradle.kts | 2 +- .../collector/proximity/ProximityCollector.kt | 32 +++--- .../proximity/ProximityCollectorTest.kt | 12 +- collector/sensor-common/build.gradle.kts | 2 +- .../sensorcommon/AndroidSensorCollector.kt | 18 +-- .../sensorcommon/SensorSourceLifecycleTest.kt | 10 +- collector/temporal-context/build.gradle.kts | 2 +- .../TemporalContextCollector.kt | 38 +++---- .../TemporalContextCollectorTest.kt | 12 +- collector/usage-events/build.gradle.kts | 2 +- .../usageevents/UsageEventsCollector.kt | 30 ++--- core/access/build.gradle.kts | 2 +- .../particeps/core/access/AccessManager.kt | 10 +- .../core/collector/CollectorContracts.kt | 10 +- .../core/collector/LatestValueRateGate.kt | 2 +- .../core/collector/ProtocolEventContracts.kt | 2 +- .../collector/SerializedCallbackCollector.kt | 4 +- .../core/collector/SourceLifecycle.kt | 2 +- .../core/collector/EventFieldContractTest.kt | 2 +- .../core/collector/LatestValueRateGateTest.kt | 2 +- .../core/collector/ProtocolEventSizeTest.kt | 6 +- .../SerializedCallbackCollectorTest.kt | 8 +- .../core/collector/SourceLifecycleTest.kt | 2 +- .../particeps/core/crypto/Ed25519Crypto.kt | 2 +- .../particeps/core/crypto/HpkeCrypto.kt | 2 +- .../core/crypto/Ed25519CryptoTest.kt | 2 +- .../particeps/core/crypto/HpkeCryptoTest.kt | 2 +- .../core/runtime/EventAdmissionGate.kt | 4 +- .../core/runtime/ExperimentRuntime.kt | 74 ++++++------- .../core/runtime/EventAdmissionGateTest.kt | 2 +- .../core/runtime/ExperimentRuntimeTest.kt | 88 +++++++-------- .../core/export/CanonicalJsonWriter.kt | 2 +- .../core/export/ResearchBundleVerifier.kt | 28 ++--- .../particeps/core/export/ResearchExport.kt | 24 ++-- .../core/export/UploadReceiptCodec.kt | 2 +- .../core/export/ResearchExportTest.kt | 32 +++--- .../particeps/core/model/ExperimentModels.kt | 2 +- .../core/model/ExperimentStateMachine.kt | 2 +- .../particeps/core/model/StudyData.kt | 2 +- .../core/model/ExperimentStateMachineTest.kt | 2 +- .../particeps/core/protocol/JoinLink.kt | 2 +- .../core/protocol/SignedConfiguration.kt | 10 +- .../protocol/ConfigurationProtocolTest.kt | 22 ++-- .../particeps/core/protocol/JoinLinkTest.kt | 2 +- core/storage/build.gradle.kts | 2 +- .../storage/EncryptedExperimentStoreTest.kt | 12 +- .../core/storage/AppendTransactionRecovery.kt | 6 +- .../core/storage/EncryptedActiveStudyStore.kt | 6 +- .../core/storage/EncryptedExperimentStore.kt | 10 +- .../particeps/core/storage/EvictionPlanner.kt | 2 +- .../core/storage/StudyDataJsonCodec.kt | 18 +-- .../storage/AppendTransactionRecoveryTest.kt | 12 +- .../core/storage/EvictionPlannerTest.kt | 2 +- .../storage/StudyDataReconciliationTest.kt | 8 +- .../InterventionSchedulePlanner.kt | 28 ++--- .../core/application/StudyApplication.kt | 56 +++++----- .../InterventionSchedulePlannerTest.kt | 44 ++++---- .../application/StudySessionManagerTest.kt | 104 +++++++++--------- .../core/definition/ProtocolBase64Url.kt | 2 +- .../core/definition/ProtocolCanonicalJson.kt | 2 +- .../core/definition/StudyConfiguration.kt | 2 +- .../definition/StudyConfigurationCodec.kt | 2 +- .../NetworkUsageConfigurationTest.kt | 2 +- .../core/definition/P2ConfigurationTest.kt | 2 +- docs/component-boundaries.md | 6 +- docs/data-collector-implementation-guide.md | 38 +++---- docs/maintainers/release.md | 8 +- docs/p0-p2-implementation-contract.md | 5 +- docs/researcher-guide.md | 4 +- docs/system-design.md | 10 +- researcher-tools/build.gradle.kts | 2 +- .../particeps/researcher/Main.kt | 18 +-- .../researcher/DecryptCommandTest.kt | 2 +- tools/catalog.py | 4 +- tools/catalog_parity.py | 10 +- .../kotlin/ProtocolConformanceTest.kt | 18 +-- tools/retired_identity_audit.py | 22 ++-- tools/tests/test_retired_identity_audit.py | 5 +- 138 files changed, 919 insertions(+), 909 deletions(-) rename app/src/androidTest/kotlin/cool/{linc => jacoblin}/particeps/AndroidConfigurationImportTest.kt (95%) rename app/src/androidTest/kotlin/cool/{linc => jacoblin}/particeps/CoreFlowTest.kt (97%) rename app/src/androidTest/kotlin/cool/{linc => jacoblin}/particeps/P2CollectorEmulatorTest.kt (87%) rename app/src/debug/kotlin/cool/{linc => jacoblin}/particeps/DemoStudy.kt (96%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/AppLocale.kt (98%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/BootRecoveryReceiver.kt (92%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/CollectionService.kt (96%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/CollectorApplication.kt (68%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/CollectorDashboard.kt (98%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/CollectorGlyphs.kt (99%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/CollectorSummary.kt (88%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/DailyStatusWorker.kt (98%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/ExperimentDeadlineWorker.kt (88%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/MainActivity.kt (95%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/StudyViewModel.kt (89%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/SurveyActivity.kt (95%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/UploadWorker.kt (94%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/platform/AndroidResearchClocks.kt (84%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/platform/AndroidStudyPlatform.kt (94%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/platform/FileUploadOutbox.kt (98%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/platform/JoinArtifactDownloader.kt (95%) rename app/src/main/kotlin/cool/{linc => jacoblin}/particeps/platform/OkHttpStudyUploader.kt (94%) rename app/src/release/kotlin/cool/{linc => jacoblin}/particeps/DemoStudy.kt (94%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/CollectorSummaryTest.kt (93%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/DemoStudyAssetTest.kt (90%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/SurveySubmissionStateTest.kt (89%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/platform/FileUploadOutboxTest.kt (97%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/platform/InterventionWorkPolicyTest.kt (92%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/platform/JoinArtifactDownloaderTest.kt (95%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/platform/OkHttpStudyUploaderTest.kt (93%) rename app/src/test/kotlin/cool/{linc => jacoblin}/particeps/platform/UploadIdentityTest.kt (83%) rename collector/accelerometer/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/accelerometer/AccelerometerCollector.kt (77%) rename collector/ambient-light/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/ambientlight/AmbientLightCollector.kt (84%) rename collector/ambient-light/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/ambientlight/AmbientLightCollectorTest.kt (89%) rename collector/app-lifecycle/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/applifecycle/AppLifecycleCollector.kt (78%) rename collector/battery-state/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/batterystate/BatteryStateCollector.kt (87%) rename collector/battery-state/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/batterystate/BatteryStateCollectorTest.kt (88%) rename collector/gyroscope/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/gyroscope/GyroscopeCollector.kt (75%) rename collector/gyroscope/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/gyroscope/GyroscopeCollectorTest.kt (84%) rename collector/keyboard-ime/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/keyboardime/ImeObservationBridge.kt (96%) rename collector/keyboard-ime/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/keyboardime/KeyboardTouchCollector.kt (76%) rename collector/keyboard-ime/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/keyboardime/ResearchInputMethodService.kt (97%) rename collector/keyboard-ime/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/keyboardime/ResearchKeyboardView.kt (99%) rename collector/keyboard-ime/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/keyboardime/ImeObservationBridgeTest.kt (98%) rename collector/location/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/location/LocationCollector.kt (84%) rename collector/network-state/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/networkstate/NetworkStateCollector.kt (82%) rename collector/network-usage/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/networkusage/NetworkUsageCollector.kt (87%) rename collector/proximity/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/proximity/ProximityCollector.kt (86%) rename collector/proximity/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/proximity/ProximityCollectorTest.kt (88%) rename collector/sensor-common/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/sensorcommon/AndroidSensorCollector.kt (86%) rename collector/sensor-common/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt (92%) rename collector/temporal-context/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/temporalcontext/TemporalContextCollector.kt (86%) rename collector/temporal-context/src/test/kotlin/cool/{linc => jacoblin}/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt (90%) rename collector/usage-events/src/main/kotlin/cool/{linc => jacoblin}/particeps/collector/usageevents/UsageEventsCollector.kt (88%) rename core/access/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/access/AccessManager.kt (94%) rename core/collector-api/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/collector/CollectorContracts.kt (97%) rename core/collector-api/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/collector/LatestValueRateGate.kt (98%) rename core/collector-api/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/collector/ProtocolEventContracts.kt (99%) rename core/collector-api/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/collector/SerializedCallbackCollector.kt (98%) rename core/collector-api/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/collector/SourceLifecycle.kt (98%) rename core/collector-api/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/collector/EventFieldContractTest.kt (95%) rename core/collector-api/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/collector/LatestValueRateGateTest.kt (98%) rename core/collector-api/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/collector/ProtocolEventSizeTest.kt (90%) rename core/collector-api/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/collector/SerializedCallbackCollectorTest.kt (97%) rename core/collector-api/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/collector/SourceLifecycleTest.kt (97%) rename core/crypto/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/crypto/Ed25519Crypto.kt (94%) rename core/crypto/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/crypto/HpkeCrypto.kt (98%) rename core/crypto/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/crypto/Ed25519CryptoTest.kt (96%) rename core/crypto/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/crypto/HpkeCryptoTest.kt (98%) rename core/experiment-runtime/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/runtime/EventAdmissionGate.kt (95%) rename core/experiment-runtime/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/runtime/ExperimentRuntime.kt (94%) rename core/experiment-runtime/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/runtime/EventAdmissionGateTest.kt (95%) rename core/experiment-runtime/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/runtime/ExperimentRuntimeTest.kt (93%) rename core/export/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/export/CanonicalJsonWriter.kt (99%) rename core/export/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/export/ResearchBundleVerifier.kt (96%) rename core/export/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/export/ResearchExport.kt (96%) rename core/export/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/export/UploadReceiptCodec.kt (98%) rename core/export/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/export/ResearchExportTest.kt (93%) rename core/model/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/model/ExperimentModels.kt (98%) rename core/model/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/model/ExperimentStateMachine.kt (98%) rename core/model/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/model/StudyData.kt (99%) rename core/model/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/model/ExperimentStateMachineTest.kt (98%) rename core/protocol/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/protocol/JoinLink.kt (99%) rename core/protocol/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/protocol/SignedConfiguration.kt (96%) rename core/protocol/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/protocol/ConfigurationProtocolTest.kt (93%) rename core/protocol/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/protocol/JoinLinkTest.kt (98%) rename core/storage/src/androidTest/kotlin/cool/{linc => jacoblin}/particeps/core/storage/EncryptedExperimentStoreTest.kt (97%) rename core/storage/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/storage/AppendTransactionRecovery.kt (96%) rename core/storage/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/storage/EncryptedActiveStudyStore.kt (97%) rename core/storage/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/storage/EncryptedExperimentStore.kt (99%) rename core/storage/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/storage/EvictionPlanner.kt (98%) rename core/storage/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/storage/StudyDataJsonCodec.kt (95%) rename core/storage/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/storage/AppendTransactionRecoveryTest.kt (92%) rename core/storage/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/storage/EvictionPlannerTest.kt (98%) rename core/storage/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/storage/StudyDataReconciliationTest.kt (89%) rename core/study-application/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/application/InterventionSchedulePlanner.kt (94%) rename core/study-application/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/application/StudyApplication.kt (95%) rename core/study-application/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/application/InterventionSchedulePlannerTest.kt (93%) rename core/study-application/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/application/StudySessionManagerTest.kt (93%) rename core/study-definition/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/definition/ProtocolBase64Url.kt (95%) rename core/study-definition/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/definition/ProtocolCanonicalJson.kt (99%) rename core/study-definition/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/definition/StudyConfiguration.kt (99%) rename core/study-definition/src/main/kotlin/cool/{linc => jacoblin}/particeps/core/definition/StudyConfigurationCodec.kt (99%) rename core/study-definition/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/definition/NetworkUsageConfigurationTest.kt (95%) rename core/study-definition/src/test/kotlin/cool/{linc => jacoblin}/particeps/core/definition/P2ConfigurationTest.kt (99%) rename researcher-tools/src/main/kotlin/cool/{linc => jacoblin}/particeps/researcher/Main.kt (95%) rename researcher-tools/src/test/kotlin/cool/{linc => jacoblin}/particeps/researcher/DecryptCommandTest.kt (98%) diff --git a/README.md b/README.md index c018cf5..fb10df9 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,10 @@ With an emulator or device attached: ``` The app suite separates the Android signed-configuration regression -([`AndroidConfigurationImportTest`](app/src/androidTest/kotlin/cool/linc/particeps/AndroidConfigurationImportTest.kt)), -the full participant UI flow ([`CoreFlowTest`](app/src/androidTest/kotlin/cool/linc/particeps/CoreFlowTest.kt)), +([`AndroidConfigurationImportTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt)), +the full participant UI flow ([`CoreFlowTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt)), and the five-collector Android integration -([`P2CollectorEmulatorTest`](app/src/androidTest/kotlin/cool/linc/particeps/P2CollectorEmulatorTest.kt)). +([`P2CollectorEmulatorTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/P2CollectorEmulatorTest.kt)). The last test skips when gyro, light, or proximity hardware is absent. Its optional exact-value mode expects a sensor-capable emulator that the host has already configured; the test does not fake Android's sensor APIs: @@ -113,7 +113,7 @@ adb -s emulator-5554 emu sensor set gyroscope 1.25:-2.5:0.5 adb -s emulator-5554 emu sensor set light 123 adb -s emulator-5554 emu sensor set proximity 1 ./gradlew :app:connectedDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=cool.linc.particeps.P2CollectorEmulatorTest \ + -Pandroid.testInstrumentationRunnerArguments.class=cool.jacoblin.particeps.P2CollectorEmulatorTest \ -Pandroid.testInstrumentationRunnerArguments.p2SyntheticInputs=true ``` @@ -178,17 +178,17 @@ flowchart LR Platform-independent modules contain no `android.*` imports, which keeps the domain logic testable on the JVM. [Component boundaries](docs/component-boundaries.md) documents the contracts. -New contributors should treat [`protocol/v1`](protocol/v1/README.md) as the normative wire contract, the [collector catalog](protocol/v1/collector-catalog.json) as the schema source, and [`docs/p0-p2-implementation-contract.md`](docs/p0-p2-implementation-contract.md) as the implementation decision record. Trace one path through the [configuration codec](core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt), [signed envelope](core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/SignedConfiguration.kt), [bundle exporter](core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchExport.kt), [bundle verifier](core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchBundleVerifier.kt), [single-entry outbox](app/src/main/kotlin/cool/linc/particeps/platform/FileUploadOutbox.kt), [HTTP adapter](app/src/main/kotlin/cool/linc/particeps/platform/OkHttpStudyUploader.kt), [receiver handler](receiver/src/index.ts), and the offline [`particeps-analysis`](particeps-analysis/README.md) pipeline. The join path is similarly short: [Web authoring](web/src/lib/particeps/join.ts), [shared parser](core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/JoinLink.kt), [Android staging](app/src/main/kotlin/cool/linc/particeps/platform/JoinArtifactDownloader.kt), [intent entry](app/src/main/kotlin/cool/linc/particeps/MainActivity.kt), then the existing [session import](core/study-application/src/main/kotlin/cool/linc/particeps/core/application/StudyApplication.kt). The [outbox](app/src/test/kotlin/cool/linc/particeps/platform/FileUploadOutboxTest.kt), [uploader](app/src/test/kotlin/cool/linc/particeps/platform/OkHttpStudyUploaderTest.kt), and [receiver](receiver/tests/receiver.test.ts) tests make crash/replay and receipt semantics executable. Receiver deployment and R2 operations start at [`receiver/README.md`](receiver/README.md), and the Collector capability policy lives under [`assurance`](assurance/README.md). +New contributors should treat [`protocol/v1`](protocol/v1/README.md) as the normative wire contract, the [collector catalog](protocol/v1/collector-catalog.json) as the schema source, and [`docs/p0-p2-implementation-contract.md`](docs/p0-p2-implementation-contract.md) as the implementation decision record. Trace one path through the [configuration codec](core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt), [signed envelope](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt), [bundle exporter](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt), [bundle verifier](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt), [single-entry outbox](app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), [HTTP adapter](app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt), [receiver handler](receiver/src/index.ts), and the offline [`particeps-analysis`](particeps-analysis/README.md) pipeline. The join path is similarly short: [Web authoring](web/src/lib/particeps/join.ts), [shared parser](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt), [Android staging](app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt), [intent entry](app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt), then the existing [session import](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt). The [outbox](app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt), [uploader](app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt), and [receiver](receiver/tests/receiver.test.ts) tests make crash/replay and receipt semantics executable. Receiver deployment and R2 operations start at [`receiver/README.md`](receiver/README.md), and the Collector capability policy lives under [`assurance`](assurance/README.md). For `random_window`, trace the signed model and bounds in -[`StudyConfiguration.kt`](core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt), +[`StudyConfiguration.kt`](core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt), its codec and [Web editor](web/src/routes/researcher/InterventionEditor.svelte), then the CSPRNG materialization in -[`InterventionSchedulePlanner.kt`](core/study-application/src/main/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlanner.kt). -The [session](core/study-application/src/main/kotlin/cool/linc/particeps/core/application/StudyApplication.kt) +[`InterventionSchedulePlanner.kt`](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt). +The [session](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt) persists the occurrence before scheduling; the Android delivery/expiry workers in -[`AndroidStudyPlatform.kt`](app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt) -and [`BootRecoveryReceiver`](app/src/main/kotlin/cool/linc/particeps/BootRecoveryReceiver.kt) +[`AndroidStudyPlatform.kt`](app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt) +and [`BootRecoveryReceiver`](app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt) reconcile the same ID after retries, reboot, clock, or time-zone changes. The adjacent planner, runtime, session, and app policy tests make each boundary executable. @@ -216,7 +216,7 @@ New collectors are the main contribution path — see [CONTRIBUTING.md](CONTRIBU ## Coming from a pre-rename release candidate -This project was called Android Data Collector through its 1.0 release candidates; every tag published so far carries that identity. The rename to Particeps moved the Android `applicationId` from `cool.linc.androiddatacollector` to `cool.linc.particeps`, and Android treats those as two different applications. There is no upgrade and no migration: installing Particeps does not see, move, or convert anything belonging to an installed pre-rename build, which keeps running under its own name until it is removed. Uninstalling it takes its Keystore keys with it, and every study, encrypted event segment, undelivered outbox bundle, and imported configuration on that install becomes unrecoverable — cloud backup and device transfer were already disabled for this app, so nothing is held anywhere else. Export whatever is still wanted before uninstalling, and re-enable the research keyboard under the new app if a study uses it. +This project was called Android Data Collector through its early 1.0 release candidates; every tag published so far carries an identity the current build no longer uses. The Android `applicationId` has moved twice since — `cool.linc.androiddatacollector`, then `cool.linc.particeps`, now `cool.jacoblin.particeps` — and the release signing key has been rotated. Android treats each of those as a different application, and the new certificate would refuse the update even if it did not. There is no upgrade and no migration: installing Particeps does not see, move, or convert anything belonging to an installed pre-rename build, which keeps running under its own name until it is removed. Uninstalling it takes its Keystore keys with it, and every study, encrypted event segment, undelivered outbox bundle, and imported configuration on that install becomes unrecoverable — cloud backup and device transfer were already disabled for this app, so nothing is held anywhere else. Export whatever is still wanted before uninstalling, and re-enable the research keyboard under the new app if a study uses it. Artifacts produced before the rename are unsupported for final Protocol v1. A `.adccfg` configuration, a `.adcexp` export, an `ADCCFG01` or `ADCEXP01` container, a `research-bundle-v1` document, an `adc://join/v1` link, and an upload carrying `application/vnd.adc.research-bundle` or any `X-ADC-*` header are invalid input to every current implementation and are rejected exactly as random bytes are. Re-sign configurations with the current tooling and re-run any pilot; there is no converter, and none will be added. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c499b24..03e87d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,11 +16,11 @@ fun Properties.requireSigningValue(name: String): String = ?: error("Missing $name in ${releaseSigningPropertiesFile.path}") android { - namespace = "cool.linc.particeps" + namespace = "cool.jacoblin.particeps" compileSdk = 37 defaultConfig { - applicationId = "cool.linc.particeps" + applicationId = "cool.jacoblin.particeps" minSdk = 34 targetSdk = 37 versionCode = providers.gradleProperty("releaseVersionCode").map(String::toInt).getOrElse(1) diff --git a/app/src/androidTest/kotlin/cool/linc/particeps/AndroidConfigurationImportTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt similarity index 95% rename from app/src/androidTest/kotlin/cool/linc/particeps/AndroidConfigurationImportTest.kt rename to app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt index 880a1d2..279737e 100644 --- a/app/src/androidTest/kotlin/cool/linc/particeps/AndroidConfigurationImportTest.kt +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt @@ -1,9 +1,9 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.storage.EncryptedActiveStudyStore +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.storage.EncryptedActiveStudyStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout diff --git a/app/src/androidTest/kotlin/cool/linc/particeps/CoreFlowTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt similarity index 97% rename from app/src/androidTest/kotlin/cool/linc/particeps/CoreFlowTest.kt rename to app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt index 84861dd..6158c2b 100644 --- a/app/src/androidTest/kotlin/cool/linc/particeps/CoreFlowTest.kt +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.Manifest import androidx.compose.ui.test.assertTextEquals @@ -10,8 +10,8 @@ import androidx.compose.ui.test.performScrollTo import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.model.ExperimentState import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/kotlin/cool/linc/particeps/P2CollectorEmulatorTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/P2CollectorEmulatorTest.kt similarity index 87% rename from app/src/androidTest/kotlin/cool/linc/particeps/P2CollectorEmulatorTest.kt rename to app/src/androidTest/kotlin/cool/jacoblin/particeps/P2CollectorEmulatorTest.kt index 25e5c23..90055a5 100644 --- a/app/src/androidTest/kotlin/cool/linc/particeps/P2CollectorEmulatorTest.kt +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/P2CollectorEmulatorTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.content.Context import android.hardware.Sensor @@ -6,27 +6,27 @@ import android.hardware.SensorManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import cool.linc.particeps.collector.ambientlight.AmbientLightCollectorPlugin -import cool.linc.particeps.collector.batterystate.BatteryStateCollectorPlugin -import cool.linc.particeps.collector.gyroscope.GyroscopeCollectorPlugin -import cool.linc.particeps.collector.proximity.ProximityCollectorPlugin -import cool.linc.particeps.collector.temporalcontext.TemporalContextCollectorPlugin -import cool.linc.particeps.core.collector.AdmissionToken -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.collector.EmitResult -import cool.linc.particeps.core.collector.EventSink -import cool.linc.particeps.core.definition.AmbientLightConfiguration -import cool.linc.particeps.core.definition.BatteryStateConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.GyroscopeConfiguration -import cool.linc.particeps.core.definition.ProximityConfiguration -import cool.linc.particeps.core.definition.TemporalContextConfiguration -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.platform.AndroidResearchClocks +import cool.jacoblin.particeps.collector.ambientlight.AmbientLightCollectorPlugin +import cool.jacoblin.particeps.collector.batterystate.BatteryStateCollectorPlugin +import cool.jacoblin.particeps.collector.gyroscope.GyroscopeCollectorPlugin +import cool.jacoblin.particeps.collector.proximity.ProximityCollectorPlugin +import cool.jacoblin.particeps.collector.temporalcontext.TemporalContextCollectorPlugin +import cool.jacoblin.particeps.core.collector.AdmissionToken +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.collector.EmitResult +import cool.jacoblin.particeps.core.collector.EventSink +import cool.jacoblin.particeps.core.definition.AmbientLightConfiguration +import cool.jacoblin.particeps.core.definition.BatteryStateConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.GyroscopeConfiguration +import cool.jacoblin.particeps.core.definition.ProximityConfiguration +import cool.jacoblin.particeps.core.definition.TemporalContextConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.platform.AndroidResearchClocks import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/app/src/debug/kotlin/cool/linc/particeps/DemoStudy.kt b/app/src/debug/kotlin/cool/jacoblin/particeps/DemoStudy.kt similarity index 96% rename from app/src/debug/kotlin/cool/linc/particeps/DemoStudy.kt rename to app/src/debug/kotlin/cool/jacoblin/particeps/DemoStudy.kt index e3c27f0..009c557 100644 --- a/app/src/debug/kotlin/cool/linc/particeps/DemoStudy.kt +++ b/app/src/debug/kotlin/cool/jacoblin/particeps/DemoStudy.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.content.res.Resources import java.util.Base64 diff --git a/app/src/main/kotlin/cool/linc/particeps/AppLocale.kt b/app/src/main/kotlin/cool/jacoblin/particeps/AppLocale.kt similarity index 98% rename from app/src/main/kotlin/cool/linc/particeps/AppLocale.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/AppLocale.kt index 6ea7ee5..5b0a848 100644 --- a/app/src/main/kotlin/cool/linc/particeps/AppLocale.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/AppLocale.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.app.LocaleConfig import android.app.LocaleManager diff --git a/app/src/main/kotlin/cool/linc/particeps/BootRecoveryReceiver.kt b/app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt similarity index 92% rename from app/src/main/kotlin/cool/linc/particeps/BootRecoveryReceiver.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt index 281d47c..aa939b5 100644 --- a/app/src/main/kotlin/cool/linc/particeps/BootRecoveryReceiver.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt @@ -1,9 +1,9 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import cool.linc.particeps.platform.InterventionDeliveryCoordinator +import cool.jacoblin.particeps.platform.InterventionDeliveryCoordinator import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/cool/linc/particeps/CollectionService.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt similarity index 96% rename from app/src/main/kotlin/cool/linc/particeps/CollectionService.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt index f1ea1c5..6631626 100644 --- a/app/src/main/kotlin/cool/linc/particeps/CollectionService.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.app.Notification import android.app.NotificationChannel @@ -58,7 +58,7 @@ class CollectionService : Service() { } companion object { - private const val ACTION_START = "cool.linc.particeps.START_COLLECTION" + private const val ACTION_START = "cool.jacoblin.particeps.START_COLLECTION" private const val EXTRA_STUDY_TITLE = "study_title" private const val EXTRA_LOCATION = "location" private const val CHANNEL_ID = "active-research-collection" diff --git a/app/src/main/kotlin/cool/linc/particeps/CollectorApplication.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt similarity index 68% rename from app/src/main/kotlin/cool/linc/particeps/CollectorApplication.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt index 41e1f66..0019ce7 100644 --- a/app/src/main/kotlin/cool/linc/particeps/CollectorApplication.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt @@ -1,43 +1,43 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.app.Application -import cool.linc.particeps.collector.accelerometer.AccelerometerCollectorPlugin -import cool.linc.particeps.collector.ambientlight.AmbientLightCollectorPlugin -import cool.linc.particeps.collector.applifecycle.AppLifecycleCollectorPlugin -import cool.linc.particeps.collector.batterystate.BatteryStateCollectorPlugin -import cool.linc.particeps.collector.gyroscope.GyroscopeCollectorPlugin -import cool.linc.particeps.collector.keyboardime.KeyboardTouchCollectorPlugin -import cool.linc.particeps.collector.keyboardime.ResearchInputMethodService -import cool.linc.particeps.collector.location.LocationCollectorPlugin -import cool.linc.particeps.collector.networkstate.NetworkStateCollectorPlugin -import cool.linc.particeps.collector.networkusage.NetworkUsageCollectorPlugin -import cool.linc.particeps.collector.proximity.ProximityCollectorPlugin -import cool.linc.particeps.collector.temporalcontext.TemporalContextCollectorPlugin -import cool.linc.particeps.collector.usageevents.UsageEventsCollectorPlugin -import cool.linc.particeps.core.access.AccessManager -import cool.linc.particeps.core.application.ExperimentRuntimeFactory -import cool.linc.particeps.core.application.StudyAccessPolicy -import cool.linc.particeps.core.application.StudyExporter -import cool.linc.particeps.core.application.StudySessionManager -import cool.linc.particeps.core.application.StudyStoreFactory -import cool.linc.particeps.core.application.StudyVerifier -import cool.linc.particeps.core.collector.CollectorRegistry -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.export.BundleKind -import cool.linc.particeps.core.export.BundleProducer -import cool.linc.particeps.core.export.ExportSnapshot -import cool.linc.particeps.core.export.ResearchExport -import cool.linc.particeps.core.protocol.ConfigurationVerifier -import cool.linc.particeps.core.runtime.ExperimentRuntime -import cool.linc.particeps.core.storage.EncryptedActiveStudyStore -import cool.linc.particeps.core.storage.EncryptedExperimentStore -import cool.linc.particeps.platform.AndroidResearchClocks -import cool.linc.particeps.platform.AndroidStudyCollectionHost -import cool.linc.particeps.platform.AndroidStudyWorkScheduler -import cool.linc.particeps.platform.FileUploadOutbox -import cool.linc.particeps.platform.InterventionDeliveryCoordinator -import cool.linc.particeps.platform.JoinArtifactDownloader -import cool.linc.particeps.platform.OkHttpStudyUploader +import cool.jacoblin.particeps.collector.accelerometer.AccelerometerCollectorPlugin +import cool.jacoblin.particeps.collector.ambientlight.AmbientLightCollectorPlugin +import cool.jacoblin.particeps.collector.applifecycle.AppLifecycleCollectorPlugin +import cool.jacoblin.particeps.collector.batterystate.BatteryStateCollectorPlugin +import cool.jacoblin.particeps.collector.gyroscope.GyroscopeCollectorPlugin +import cool.jacoblin.particeps.collector.keyboardime.KeyboardTouchCollectorPlugin +import cool.jacoblin.particeps.collector.keyboardime.ResearchInputMethodService +import cool.jacoblin.particeps.collector.location.LocationCollectorPlugin +import cool.jacoblin.particeps.collector.networkstate.NetworkStateCollectorPlugin +import cool.jacoblin.particeps.collector.networkusage.NetworkUsageCollectorPlugin +import cool.jacoblin.particeps.collector.proximity.ProximityCollectorPlugin +import cool.jacoblin.particeps.collector.temporalcontext.TemporalContextCollectorPlugin +import cool.jacoblin.particeps.collector.usageevents.UsageEventsCollectorPlugin +import cool.jacoblin.particeps.core.access.AccessManager +import cool.jacoblin.particeps.core.application.ExperimentRuntimeFactory +import cool.jacoblin.particeps.core.application.StudyAccessPolicy +import cool.jacoblin.particeps.core.application.StudyExporter +import cool.jacoblin.particeps.core.application.StudySessionManager +import cool.jacoblin.particeps.core.application.StudyStoreFactory +import cool.jacoblin.particeps.core.application.StudyVerifier +import cool.jacoblin.particeps.core.collector.CollectorRegistry +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.export.BundleKind +import cool.jacoblin.particeps.core.export.BundleProducer +import cool.jacoblin.particeps.core.export.ExportSnapshot +import cool.jacoblin.particeps.core.export.ResearchExport +import cool.jacoblin.particeps.core.protocol.ConfigurationVerifier +import cool.jacoblin.particeps.core.runtime.ExperimentRuntime +import cool.jacoblin.particeps.core.storage.EncryptedActiveStudyStore +import cool.jacoblin.particeps.core.storage.EncryptedExperimentStore +import cool.jacoblin.particeps.platform.AndroidResearchClocks +import cool.jacoblin.particeps.platform.AndroidStudyCollectionHost +import cool.jacoblin.particeps.platform.AndroidStudyWorkScheduler +import cool.jacoblin.particeps.platform.FileUploadOutbox +import cool.jacoblin.particeps.platform.InterventionDeliveryCoordinator +import cool.jacoblin.particeps.platform.JoinArtifactDownloader +import cool.jacoblin.particeps.platform.OkHttpStudyUploader import java.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt similarity index 98% rename from app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt index 5effce6..8b875f2 100644 --- a/app/src/main/kotlin/cool/linc/particeps/CollectorDashboard.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.text.format.DateUtils import androidx.compose.foundation.Canvas @@ -53,13 +53,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessStatus -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.UploadConfiguration -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.StudyMetadata import java.text.NumberFormat import kotlinx.coroutines.delay diff --git a/app/src/main/kotlin/cool/linc/particeps/CollectorGlyphs.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorGlyphs.kt similarity index 99% rename from app/src/main/kotlin/cool/linc/particeps/CollectorGlyphs.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/CollectorGlyphs.kt index bb8b64b..9c77935 100644 --- a/app/src/main/kotlin/cool/linc/particeps/CollectorGlyphs.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorGlyphs.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size diff --git a/app/src/main/kotlin/cool/linc/particeps/CollectorSummary.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorSummary.kt similarity index 88% rename from app/src/main/kotlin/cool/linc/particeps/CollectorSummary.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/CollectorSummary.kt index d175c12..854c8c0 100644 --- a/app/src/main/kotlin/cool/linc/particeps/CollectorSummary.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorSummary.kt @@ -1,22 +1,22 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import cool.linc.particeps.core.definition.AccelerometerConfiguration -import cool.linc.particeps.core.definition.AmbientLightConfiguration -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.BatteryStateConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.KeyboardTouchConfiguration -import cool.linc.particeps.core.definition.GyroscopeConfiguration -import cool.linc.particeps.core.definition.LocationConfiguration -import cool.linc.particeps.core.definition.NetworkStateConfiguration -import cool.linc.particeps.core.definition.NetworkUsageConfiguration -import cool.linc.particeps.core.definition.ProximityConfiguration -import cool.linc.particeps.core.definition.TemporalContextConfiguration -import cool.linc.particeps.core.definition.UsageEventsConfiguration -import cool.linc.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.definition.AccelerometerConfiguration +import cool.jacoblin.particeps.core.definition.AmbientLightConfiguration +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.BatteryStateConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.KeyboardTouchConfiguration +import cool.jacoblin.particeps.core.definition.GyroscopeConfiguration +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.NetworkStateConfiguration +import cool.jacoblin.particeps.core.definition.NetworkUsageConfiguration +import cool.jacoblin.particeps.core.definition.ProximityConfiguration +import cool.jacoblin.particeps.core.definition.TemporalContextConfiguration +import cool.jacoblin.particeps.core.definition.UsageEventsConfiguration +import cool.jacoblin.particeps.core.definition.UploadConfiguration /** * One collector, described to the participant in their own language. diff --git a/app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt similarity index 98% rename from app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt index 17f7015..0383e00 100644 --- a/app/src/main/kotlin/cool/linc/particeps/DailyStatusWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.Manifest import android.app.NotificationChannel @@ -10,7 +10,7 @@ import android.content.pm.PackageManager import android.text.format.DateUtils import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import cool.linc.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentState import kotlinx.coroutines.flow.first /** diff --git a/app/src/main/kotlin/cool/linc/particeps/ExperimentDeadlineWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt similarity index 88% rename from app/src/main/kotlin/cool/linc/particeps/ExperimentDeadlineWorker.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt index 072a447..7150098 100644 --- a/app/src/main/kotlin/cool/linc/particeps/ExperimentDeadlineWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt @@ -1,10 +1,10 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.runtime.CommandResult +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.runtime.CommandResult import kotlinx.coroutines.flow.first class ExperimentDeadlineWorker( diff --git a/app/src/main/kotlin/cool/linc/particeps/MainActivity.kt b/app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt similarity index 95% rename from app/src/main/kotlin/cool/linc/particeps/MainActivity.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt index 0744132..7981038 100644 --- a/app/src/main/kotlin/cool/linc/particeps/MainActivity.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.Manifest import android.content.Intent @@ -10,10 +10,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import cool.linc.particeps.platform.InterventionWorker -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.platform.InterventionWorker +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec import java.time.Instant import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/cool/linc/particeps/StudyViewModel.kt b/app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt similarity index 89% rename from app/src/main/kotlin/cool/linc/particeps/StudyViewModel.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt index 5b39ddc..3d84bf7 100644 --- a/app/src/main/kotlin/cool/linc/particeps/StudyViewModel.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt @@ -1,18 +1,18 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import cool.linc.particeps.core.application.StudySessionManager -import cool.linc.particeps.core.application.UploadStatus -import cool.linc.particeps.core.collector.AccessStatus -import cool.linc.particeps.core.collector.CollectorHealth -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.export.ExportReceipt -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.SignedConfigurationCodec -import cool.linc.particeps.core.runtime.CommandResult +import cool.jacoblin.particeps.core.application.StudySessionManager +import cool.jacoblin.particeps.core.application.UploadStatus +import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.CollectorHealth +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.core.runtime.CommandResult import java.io.OutputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/kotlin/cool/linc/particeps/SurveyActivity.kt b/app/src/main/kotlin/cool/jacoblin/particeps/SurveyActivity.kt similarity index 95% rename from app/src/main/kotlin/cool/linc/particeps/SurveyActivity.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/SurveyActivity.kt index 6c327cd..40792f6 100644 --- a/app/src/main/kotlin/cool/linc/particeps/SurveyActivity.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/SurveyActivity.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.os.Bundle import androidx.activity.ComponentActivity @@ -47,17 +47,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope -import cool.linc.particeps.core.application.StudySessionManager -import cool.linc.particeps.core.definition.MultipleChoiceQuestion -import cool.linc.particeps.core.definition.ScaleQuestion -import cool.linc.particeps.core.definition.ShortTextQuestion -import cool.linc.particeps.core.definition.SingleChoiceQuestion -import cool.linc.particeps.core.definition.SurveyAction -import cool.linc.particeps.core.definition.SurveyDefinition -import cool.linc.particeps.core.definition.SurveyQuestion -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.runtime.SurveyAnswer -import cool.linc.particeps.core.runtime.SurveySubmissionResult +import cool.jacoblin.particeps.core.application.StudySessionManager +import cool.jacoblin.particeps.core.definition.MultipleChoiceQuestion +import cool.jacoblin.particeps.core.definition.ScaleQuestion +import cool.jacoblin.particeps.core.definition.ShortTextQuestion +import cool.jacoblin.particeps.core.definition.SingleChoiceQuestion +import cool.jacoblin.particeps.core.definition.SurveyAction +import cool.jacoblin.particeps.core.definition.SurveyDefinition +import cool.jacoblin.particeps.core.definition.SurveyQuestion +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.runtime.SurveyAnswer +import cool.jacoblin.particeps.core.runtime.SurveySubmissionResult import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first diff --git a/app/src/main/kotlin/cool/linc/particeps/UploadWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt similarity index 94% rename from app/src/main/kotlin/cool/linc/particeps/UploadWorker.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt index afd2ea3..78622d0 100644 --- a/app/src/main/kotlin/cool/linc/particeps/UploadWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt @@ -1,11 +1,11 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.content.Context import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy import androidx.work.WorkerParameters -import cool.linc.particeps.core.application.UploadAttemptResult -import cool.linc.particeps.platform.AndroidStudyWorkScheduler +import cool.jacoblin.particeps.core.application.UploadAttemptResult +import cool.jacoblin.particeps.platform.AndroidStudyWorkScheduler import kotlinx.coroutines.flow.first /** diff --git a/app/src/main/kotlin/cool/linc/particeps/platform/AndroidResearchClocks.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidResearchClocks.kt similarity index 84% rename from app/src/main/kotlin/cool/linc/particeps/platform/AndroidResearchClocks.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidResearchClocks.kt index e62719e..d64e8da 100644 --- a/app/src/main/kotlin/cool/linc/particeps/platform/AndroidResearchClocks.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidResearchClocks.kt @@ -1,10 +1,10 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform import android.content.Context import android.os.SystemClock import android.provider.Settings -import cool.linc.particeps.core.collector.ResearchClocks -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.ResearchClocks +import cool.jacoblin.particeps.core.model.ResearchTime import java.security.MessageDigest class AndroidResearchClocks( diff --git a/app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt similarity index 94% rename from app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt index ccb4c61..2d6bed6 100644 --- a/app/src/main/kotlin/cool/linc/particeps/platform/AndroidStudyPlatform.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform import android.Manifest import android.app.NotificationChannel @@ -19,21 +19,21 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import cool.linc.particeps.CollectionService -import cool.linc.particeps.DailyStatusWorker -import cool.linc.particeps.ExperimentDeadlineWorker -import cool.linc.particeps.MainActivity -import cool.linc.particeps.R -import cool.linc.particeps.SurveyActivity -import cool.linc.particeps.UploadWorker -import cool.linc.particeps.core.application.StudyCollectionHost -import cool.linc.particeps.core.application.StudyWorkScheduler -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.SurveyAction -import cool.linc.particeps.core.definition.UploadConfiguration -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.runtime.OccurrenceClaimResult -import cool.linc.particeps.core.runtime.OccurrenceExpiryResult +import cool.jacoblin.particeps.CollectionService +import cool.jacoblin.particeps.DailyStatusWorker +import cool.jacoblin.particeps.ExperimentDeadlineWorker +import cool.jacoblin.particeps.MainActivity +import cool.jacoblin.particeps.R +import cool.jacoblin.particeps.SurveyActivity +import cool.jacoblin.particeps.UploadWorker +import cool.jacoblin.particeps.core.application.StudyCollectionHost +import cool.jacoblin.particeps.core.application.StudyWorkScheduler +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.SurveyAction +import cool.jacoblin.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.runtime.OccurrenceClaimResult +import cool.jacoblin.particeps.core.runtime.OccurrenceExpiryResult import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first @@ -339,7 +339,7 @@ class InterventionWorker( private suspend fun deliver(): Result { val occurrenceId = inputData.getString(KEY_OCCURRENCE_ID) ?: return Result.failure() - val application = applicationContext as cool.linc.particeps.CollectorApplication + val application = applicationContext as cool.jacoblin.particeps.CollectorApplication if (application.session.snapshot.first { it.initialized }.configuration == null) return Result.success() val claim = application.session.claimOccurrenceIfDue(occurrenceId) when (deliveryWorkerDirective(claim)) { @@ -420,7 +420,7 @@ class InterventionWorker( companion object { const val KEY_OCCURRENCE_ID = "occurrence_id" - const val ACTION_OPEN_OCCURRENCE = "cool.linc.particeps.OPEN_OCCURRENCE" + const val ACTION_OPEN_OCCURRENCE = "cool.jacoblin.particeps.OPEN_OCCURRENCE" private const val CHANNEL_ID = "research-interventions-v1" } } @@ -440,7 +440,7 @@ class InterventionExpiryWorker( private suspend fun expire(): Result { val occurrenceId = inputData.getString(InterventionWorker.KEY_OCCURRENCE_ID) ?: return Result.failure() - val application = applicationContext as cool.linc.particeps.CollectorApplication + val application = applicationContext as cool.jacoblin.particeps.CollectorApplication if (application.session.snapshot.first { it.initialized }.configuration == null) return Result.success() return when (expiryWorkerDirective(application.session.expireOccurrenceIfDue(occurrenceId))) { ExpiryWorkerDirective.RETRY -> Result.retry() diff --git a/app/src/main/kotlin/cool/linc/particeps/platform/FileUploadOutbox.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt similarity index 98% rename from app/src/main/kotlin/cool/linc/particeps/platform/FileUploadOutbox.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt index bdfad50..83396ee 100644 --- a/app/src/main/kotlin/cool/linc/particeps/platform/FileUploadOutbox.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt @@ -1,9 +1,9 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform import android.system.Os import android.system.OsConstants -import cool.linc.particeps.core.application.StudyUploadException -import cool.linc.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.application.StudyUploadException +import cool.jacoblin.particeps.core.export.ExportReceipt import java.io.BufferedInputStream import java.io.DataInputStream import java.io.DataOutputStream diff --git a/app/src/main/kotlin/cool/linc/particeps/platform/JoinArtifactDownloader.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt similarity index 95% rename from app/src/main/kotlin/cool/linc/particeps/platform/JoinArtifactDownloader.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt index b3d08e2..e9ca44f 100644 --- a/app/src/main/kotlin/cool/linc/particeps/platform/JoinArtifactDownloader.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec import java.io.File import java.io.FileOutputStream import java.io.IOException diff --git a/app/src/main/kotlin/cool/linc/particeps/platform/OkHttpStudyUploader.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt similarity index 94% rename from app/src/main/kotlin/cool/linc/particeps/platform/OkHttpStudyUploader.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt index d056c50..00c3dd4 100644 --- a/app/src/main/kotlin/cool/linc/particeps/platform/OkHttpStudyUploader.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt @@ -1,16 +1,16 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform -import cool.linc.particeps.core.application.StudyUploadException -import cool.linc.particeps.core.application.StudyUploader -import cool.linc.particeps.core.export.BundleKind -import cool.linc.particeps.core.export.BundleProducer -import cool.linc.particeps.core.export.ExportReceipt -import cool.linc.particeps.core.export.ExportSnapshot -import cool.linc.particeps.core.export.ResearchExport -import cool.linc.particeps.core.export.UploadReceiptCodec -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.protocol.VerifiedConfiguration +import cool.jacoblin.particeps.core.application.StudyUploadException +import cool.jacoblin.particeps.core.application.StudyUploader +import cool.jacoblin.particeps.core.export.BundleKind +import cool.jacoblin.particeps.core.export.BundleProducer +import cool.jacoblin.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.export.ExportSnapshot +import cool.jacoblin.particeps.core.export.ResearchExport +import cool.jacoblin.particeps.core.export.UploadReceiptCodec +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.io.IOException import java.util.Base64 import java.util.concurrent.atomic.AtomicBoolean diff --git a/app/src/release/kotlin/cool/linc/particeps/DemoStudy.kt b/app/src/release/kotlin/cool/jacoblin/particeps/DemoStudy.kt similarity index 94% rename from app/src/release/kotlin/cool/linc/particeps/DemoStudy.kt rename to app/src/release/kotlin/cool/jacoblin/particeps/DemoStudy.kt index e2f4852..6b47ebb 100644 --- a/app/src/release/kotlin/cool/linc/particeps/DemoStudy.kt +++ b/app/src/release/kotlin/cool/jacoblin/particeps/DemoStudy.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import android.content.res.Resources diff --git a/app/src/test/kotlin/cool/linc/particeps/CollectorSummaryTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/CollectorSummaryTest.kt similarity index 93% rename from app/src/test/kotlin/cool/linc/particeps/CollectorSummaryTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/CollectorSummaryTest.kt index 68d597a..7852ec7 100644 --- a/app/src/test/kotlin/cool/linc/particeps/CollectorSummaryTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/CollectorSummaryTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps +package cool.jacoblin.particeps import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/kotlin/cool/linc/particeps/DemoStudyAssetTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/DemoStudyAssetTest.kt similarity index 90% rename from app/src/test/kotlin/cool/linc/particeps/DemoStudyAssetTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/DemoStudyAssetTest.kt index 6607dbf..a5b780c 100644 --- a/app/src/test/kotlin/cool/linc/particeps/DemoStudyAssetTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/DemoStudyAssetTest.kt @@ -1,6 +1,6 @@ -package cool.linc.particeps +package cool.jacoblin.particeps -import cool.linc.particeps.core.protocol.ConfigurationVerifier +import cool.jacoblin.particeps.core.protocol.ConfigurationVerifier import java.nio.file.Files import java.nio.file.Path import java.time.Instant diff --git a/app/src/test/kotlin/cool/linc/particeps/SurveySubmissionStateTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/SurveySubmissionStateTest.kt similarity index 89% rename from app/src/test/kotlin/cool/linc/particeps/SurveySubmissionStateTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/SurveySubmissionStateTest.kt index dc7e01c..6245565 100644 --- a/app/src/test/kotlin/cool/linc/particeps/SurveySubmissionStateTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/SurveySubmissionStateTest.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps +package cool.jacoblin.particeps -import cool.linc.particeps.core.runtime.SurveyAnswer -import cool.linc.particeps.core.runtime.SurveySubmissionResult +import cool.jacoblin.particeps.core.runtime.SurveyAnswer +import cool.jacoblin.particeps.core.runtime.SurveySubmissionResult import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/kotlin/cool/linc/particeps/platform/FileUploadOutboxTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt similarity index 97% rename from app/src/test/kotlin/cool/linc/particeps/platform/FileUploadOutboxTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt index efec28c..ed92307 100644 --- a/app/src/test/kotlin/cool/linc/particeps/platform/FileUploadOutboxTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform -import cool.linc.particeps.core.application.StudyUploadException -import cool.linc.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.application.StudyUploadException +import cool.jacoblin.particeps.core.export.ExportReceipt import java.io.IOException import java.security.MessageDigest import java.util.UUID diff --git a/app/src/test/kotlin/cool/linc/particeps/platform/InterventionWorkPolicyTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/InterventionWorkPolicyTest.kt similarity index 92% rename from app/src/test/kotlin/cool/linc/particeps/platform/InterventionWorkPolicyTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/platform/InterventionWorkPolicyTest.kt index d681471..328cd59 100644 --- a/app/src/test/kotlin/cool/linc/particeps/platform/InterventionWorkPolicyTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/InterventionWorkPolicyTest.kt @@ -1,12 +1,12 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform -import cool.linc.particeps.core.definition.NotificationAction -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.runtime.OccurrenceClaimResult -import cool.linc.particeps.core.runtime.OccurrenceDispatch -import cool.linc.particeps.core.runtime.OccurrenceExpiryResult +import cool.jacoblin.particeps.core.definition.NotificationAction +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.runtime.OccurrenceClaimResult +import cool.jacoblin.particeps.core.runtime.OccurrenceDispatch +import cool.jacoblin.particeps.core.runtime.OccurrenceExpiryResult import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope diff --git a/app/src/test/kotlin/cool/linc/particeps/platform/JoinArtifactDownloaderTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloaderTest.kt similarity index 95% rename from app/src/test/kotlin/cool/linc/particeps/platform/JoinArtifactDownloaderTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloaderTest.kt index 3666139..da94f0f 100644 --- a/app/src/test/kotlin/cool/linc/particeps/platform/JoinArtifactDownloaderTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloaderTest.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec import java.net.URI import java.nio.file.Files import java.security.MessageDigest diff --git a/app/src/test/kotlin/cool/linc/particeps/platform/OkHttpStudyUploaderTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt similarity index 93% rename from app/src/test/kotlin/cool/linc/particeps/platform/OkHttpStudyUploaderTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt index 81f8d6b..9dc9b5c 100644 --- a/app/src/test/kotlin/cool/linc/particeps/platform/OkHttpStudyUploaderTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt @@ -1,24 +1,24 @@ -package cool.linc.particeps.platform - -import cool.linc.particeps.core.application.StudyUploadException -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.definition.UploadConfiguration -import cool.linc.particeps.core.export.BundleProducer -import cool.linc.particeps.core.export.ExportReceipt -import cool.linc.particeps.core.export.ResearchExport -import cool.linc.particeps.core.export.UploadReceiptCodec -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StorageUsage -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore +package cool.jacoblin.particeps.platform + +import cool.jacoblin.particeps.core.application.StudyUploadException +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.export.BundleProducer +import cool.jacoblin.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.export.ResearchExport +import cool.jacoblin.particeps.core.export.UploadReceiptCodec +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StorageUsage +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore import com.google.gson.JsonParser -import cool.linc.particeps.core.protocol.VerifiedConfiguration +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.io.IOException import java.nio.file.Path import java.security.MessageDigest diff --git a/app/src/test/kotlin/cool/linc/particeps/platform/UploadIdentityTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/UploadIdentityTest.kt similarity index 83% rename from app/src/test/kotlin/cool/linc/particeps/platform/UploadIdentityTest.kt rename to app/src/test/kotlin/cool/jacoblin/particeps/platform/UploadIdentityTest.kt index 36e9704..12edab1 100644 --- a/app/src/test/kotlin/cool/linc/particeps/platform/UploadIdentityTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/UploadIdentityTest.kt @@ -1,13 +1,13 @@ -package cool.linc.particeps.platform +package cool.jacoblin.particeps.platform -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.UploadConfiguration -import cool.linc.particeps.core.export.ExportReceipt -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.protocol.VerifiedConfiguration +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.time.Instant import java.util.UUID import org.junit.Assert.assertEquals diff --git a/assurance/collector-policy.json b/assurance/collector-policy.json index 33ac763..a53f005 100644 --- a/assurance/collector-policy.json +++ b/assurance/collector-policy.json @@ -16,10 +16,10 @@ "androidx/datastore/", "androidx/preference/", "androidx/room/", - "cool/linc/particeps/core/crypto/", - "cool/linc/particeps/core/export/", - "cool/linc/particeps/core/protocol/", - "cool/linc/particeps/core/storage/", + "cool/jacoblin/particeps/core/crypto/", + "cool/jacoblin/particeps/core/export/", + "cool/jacoblin/particeps/core/protocol/", + "cool/jacoblin/particeps/core/storage/", "dalvik/system/", "java/io/", "java/lang/reflect/", @@ -61,10 +61,10 @@ "androidx.datastore.", "androidx.preference.", "androidx.room.", - "cool.linc.particeps.core.crypto.", - "cool.linc.particeps.core.export.", - "cool.linc.particeps.core.protocol.", - "cool.linc.particeps.core.storage.", + "cool.jacoblin.particeps.core.crypto.", + "cool.jacoblin.particeps.core.export.", + "cool.jacoblin.particeps.core.protocol.", + "cool.jacoblin.particeps.core.storage.", "dalvik.system.", "java.io.", "java.lang.ClassLoader", diff --git a/collector/accelerometer/build.gradle.kts b/collector/accelerometer/build.gradle.kts index 69be312..566c149 100644 --- a/collector/accelerometer/build.gradle.kts +++ b/collector/accelerometer/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.accelerometer" + namespace = "cool.jacoblin.particeps.collector.accelerometer" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/accelerometer/src/main/kotlin/cool/linc/particeps/collector/accelerometer/AccelerometerCollector.kt b/collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt similarity index 77% rename from collector/accelerometer/src/main/kotlin/cool/linc/particeps/collector/accelerometer/AccelerometerCollector.kt rename to collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt index 79d59fc..e964dbc 100644 --- a/collector/accelerometer/src/main/kotlin/cool/linc/particeps/collector/accelerometer/AccelerometerCollector.kt +++ b/collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt @@ -1,20 +1,20 @@ -package cool.linc.particeps.collector.accelerometer +package cool.jacoblin.particeps.collector.accelerometer import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent -import cool.linc.particeps.collector.sensorcommon.AndroidSensorCollector -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.definition.AccelerometerConfiguration -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.definition.AccelerometerConfiguration +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin class AccelerometerCollectorPlugin( context: Context, diff --git a/collector/ambient-light/build.gradle.kts b/collector/ambient-light/build.gradle.kts index 91e0e36..e4aaa51 100644 --- a/collector/ambient-light/build.gradle.kts +++ b/collector/ambient-light/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.ambientlight" + namespace = "cool.jacoblin.particeps.collector.ambientlight" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/ambient-light/src/main/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollector.kt b/collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt similarity index 84% rename from collector/ambient-light/src/main/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollector.kt rename to collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt index c620763..b4d731a 100644 --- a/collector/ambient-light/src/main/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollector.kt +++ b/collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt @@ -1,24 +1,24 @@ -package cool.linc.particeps.collector.ambientlight +package cool.jacoblin.particeps.collector.ambientlight import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.os.SystemClock -import cool.linc.particeps.collector.sensorcommon.AndroidSensorCollector -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.AmbientLightConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.AmbientLightConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import kotlin.math.abs class AmbientLightCollectorPlugin(context: Context) : CollectorPlugin { diff --git a/collector/ambient-light/src/test/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollectorTest.kt b/collector/ambient-light/src/test/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollectorTest.kt similarity index 89% rename from collector/ambient-light/src/test/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollectorTest.kt rename to collector/ambient-light/src/test/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollectorTest.kt index 247a074..20f2e3a 100644 --- a/collector/ambient-light/src/test/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollectorTest.kt +++ b/collector/ambient-light/src/test/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollectorTest.kt @@ -1,10 +1,10 @@ -package cool.linc.particeps.collector.ambientlight +package cool.jacoblin.particeps.collector.ambientlight -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.AmbientLightConfiguration -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.AmbientLightConfiguration +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue diff --git a/collector/app-lifecycle/build.gradle.kts b/collector/app-lifecycle/build.gradle.kts index 3782949..fb9f39c 100644 --- a/collector/app-lifecycle/build.gradle.kts +++ b/collector/app-lifecycle/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.applifecycle" + namespace = "cool.jacoblin.particeps.collector.applifecycle" compileSdk = 37 defaultConfig { diff --git a/collector/app-lifecycle/src/main/kotlin/cool/linc/particeps/collector/applifecycle/AppLifecycleCollector.kt b/collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt similarity index 78% rename from collector/app-lifecycle/src/main/kotlin/cool/linc/particeps/collector/applifecycle/AppLifecycleCollector.kt rename to collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt index 908a475..af35bd5 100644 --- a/collector/app-lifecycle/src/main/kotlin/cool/linc/particeps/collector/applifecycle/AppLifecycleCollector.kt +++ b/collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt @@ -1,21 +1,21 @@ -package cool.linc.particeps.collector.applifecycle +package cool.jacoblin.particeps.collector.applifecycle import android.app.Activity import android.app.Application import android.os.Bundle -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/collector/battery-state/build.gradle.kts b/collector/battery-state/build.gradle.kts index 6afc401..09a6ebd 100644 --- a/collector/battery-state/build.gradle.kts +++ b/collector/battery-state/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.batterystate" + namespace = "cool.jacoblin.particeps.collector.batterystate" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/battery-state/src/main/kotlin/cool/linc/particeps/collector/batterystate/BatteryStateCollector.kt b/collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt similarity index 87% rename from collector/battery-state/src/main/kotlin/cool/linc/particeps/collector/batterystate/BatteryStateCollector.kt rename to collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt index 9a88ab3..8214934 100644 --- a/collector/battery-state/src/main/kotlin/cool/linc/particeps/collector/batterystate/BatteryStateCollector.kt +++ b/collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.collector.batterystate +package cool.jacoblin.particeps.collector.batterystate import android.content.BroadcastReceiver import android.content.Context @@ -9,24 +9,24 @@ import android.os.Handler import android.os.Looper import android.os.PowerManager import android.os.SystemClock -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult -import cool.linc.particeps.core.collector.completeSourceTeardown -import cool.linc.particeps.core.collector.registerSourceWithRollback -import cool.linc.particeps.core.definition.BatteryStateConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.collector.completeSourceTeardown +import cool.jacoblin.particeps.core.collector.registerSourceWithRollback +import cool.jacoblin.particeps.core.definition.BatteryStateConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/collector/battery-state/src/test/kotlin/cool/linc/particeps/collector/batterystate/BatteryStateCollectorTest.kt b/collector/battery-state/src/test/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollectorTest.kt similarity index 88% rename from collector/battery-state/src/test/kotlin/cool/linc/particeps/collector/batterystate/BatteryStateCollectorTest.kt rename to collector/battery-state/src/test/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollectorTest.kt index 237267c..6bd353f 100644 --- a/collector/battery-state/src/test/kotlin/cool/linc/particeps/collector/batterystate/BatteryStateCollectorTest.kt +++ b/collector/battery-state/src/test/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollectorTest.kt @@ -1,11 +1,11 @@ -package cool.linc.particeps.collector.batterystate +package cool.jacoblin.particeps.collector.batterystate import android.os.BatteryManager -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.BatteryStateConfiguration -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.BatteryStateConfiguration +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/collector/gyroscope/build.gradle.kts b/collector/gyroscope/build.gradle.kts index a6b1329..2266f38 100644 --- a/collector/gyroscope/build.gradle.kts +++ b/collector/gyroscope/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.gyroscope" + namespace = "cool.jacoblin.particeps.collector.gyroscope" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/gyroscope/src/main/kotlin/cool/linc/particeps/collector/gyroscope/GyroscopeCollector.kt b/collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt similarity index 75% rename from collector/gyroscope/src/main/kotlin/cool/linc/particeps/collector/gyroscope/GyroscopeCollector.kt rename to collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt index 4baf90a..85cc65b 100644 --- a/collector/gyroscope/src/main/kotlin/cool/linc/particeps/collector/gyroscope/GyroscopeCollector.kt +++ b/collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt @@ -1,21 +1,21 @@ -package cool.linc.particeps.collector.gyroscope +package cool.jacoblin.particeps.collector.gyroscope import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent -import cool.linc.particeps.collector.sensorcommon.AndroidSensorCollector -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.GyroscopeConfiguration -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.GyroscopeConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.ResearchTime class GyroscopeCollectorPlugin(context: Context) : CollectorPlugin { private val applicationContext = context.applicationContext diff --git a/collector/gyroscope/src/test/kotlin/cool/linc/particeps/collector/gyroscope/GyroscopeCollectorTest.kt b/collector/gyroscope/src/test/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollectorTest.kt similarity index 84% rename from collector/gyroscope/src/test/kotlin/cool/linc/particeps/collector/gyroscope/GyroscopeCollectorTest.kt rename to collector/gyroscope/src/test/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollectorTest.kt index 1f85ea9..72ca3dc 100644 --- a/collector/gyroscope/src/test/kotlin/cool/linc/particeps/collector/gyroscope/GyroscopeCollectorTest.kt +++ b/collector/gyroscope/src/test/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollectorTest.kt @@ -1,8 +1,8 @@ -package cool.linc.particeps.collector.gyroscope +package cool.jacoblin.particeps.collector.gyroscope -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.GyroscopeConfiguration -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.GyroscopeConfiguration +import cool.jacoblin.particeps.core.model.ResearchTime import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue diff --git a/collector/keyboard-ime/build.gradle.kts b/collector/keyboard-ime/build.gradle.kts index 15ef5bd..2ead78a 100644 --- a/collector/keyboard-ime/build.gradle.kts +++ b/collector/keyboard-ime/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.keyboardime" + namespace = "cool.jacoblin.particeps.collector.keyboardime" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ImeObservationBridge.kt b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ImeObservationBridge.kt similarity index 96% rename from collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ImeObservationBridge.kt rename to collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ImeObservationBridge.kt index 614e9b7..55ab60a 100644 --- a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ImeObservationBridge.kt +++ b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ImeObservationBridge.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps.collector.keyboardime +package cool.jacoblin.particeps.collector.keyboardime import android.view.MotionEvent -import cool.linc.particeps.core.collector.SourceCallbackBoundary +import cool.jacoblin.particeps.core.collector.SourceCallbackBoundary internal data class ImeTouchObservation( val action: String, diff --git a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/KeyboardTouchCollector.kt b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt similarity index 76% rename from collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/KeyboardTouchCollector.kt rename to collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt index a3a4f95..3d5edbc 100644 --- a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/KeyboardTouchCollector.kt +++ b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt @@ -1,19 +1,19 @@ -package cool.linc.particeps.collector.keyboardime +package cool.jacoblin.particeps.collector.keyboardime -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.KeyboardTouchConfiguration -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.KeyboardTouchConfiguration +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult class KeyboardTouchCollectorPlugin : CollectorPlugin { override val descriptor = CollectorDescriptor( diff --git a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ResearchInputMethodService.kt b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchInputMethodService.kt similarity index 97% rename from collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ResearchInputMethodService.kt rename to collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchInputMethodService.kt index 3bc2707..6c65574 100644 --- a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ResearchInputMethodService.kt +++ b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchInputMethodService.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.collector.keyboardime +package cool.jacoblin.particeps.collector.keyboardime import android.inputmethodservice.InputMethodService import android.text.InputType diff --git a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ResearchKeyboardView.kt b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt similarity index 99% rename from collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ResearchKeyboardView.kt rename to collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt index f5c3a6b..10d334d 100644 --- a/collector/keyboard-ime/src/main/kotlin/cool/linc/particeps/collector/keyboardime/ResearchKeyboardView.kt +++ b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.collector.keyboardime +package cool.jacoblin.particeps.collector.keyboardime import android.content.Context import android.graphics.Canvas diff --git a/collector/keyboard-ime/src/main/res/xml/research_input_method.xml b/collector/keyboard-ime/src/main/res/xml/research_input_method.xml index 9700a8b..9935ace 100644 --- a/collector/keyboard-ime/src/main/res/xml/research_input_method.xml +++ b/collector/keyboard-ime/src/main/res/xml/research_input_method.xml @@ -1,4 +1,4 @@ diff --git a/collector/keyboard-ime/src/test/kotlin/cool/linc/particeps/collector/keyboardime/ImeObservationBridgeTest.kt b/collector/keyboard-ime/src/test/kotlin/cool/jacoblin/particeps/collector/keyboardime/ImeObservationBridgeTest.kt similarity index 98% rename from collector/keyboard-ime/src/test/kotlin/cool/linc/particeps/collector/keyboardime/ImeObservationBridgeTest.kt rename to collector/keyboard-ime/src/test/kotlin/cool/jacoblin/particeps/collector/keyboardime/ImeObservationBridgeTest.kt index a6b8b05..db53bbc 100644 --- a/collector/keyboard-ime/src/test/kotlin/cool/linc/particeps/collector/keyboardime/ImeObservationBridgeTest.kt +++ b/collector/keyboard-ime/src/test/kotlin/cool/jacoblin/particeps/collector/keyboardime/ImeObservationBridgeTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.collector.keyboardime +package cool.jacoblin.particeps.collector.keyboardime import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit diff --git a/collector/location/build.gradle.kts b/collector/location/build.gradle.kts index 6a386f8..9c36bf6 100644 --- a/collector/location/build.gradle.kts +++ b/collector/location/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.location" + namespace = "cool.jacoblin.particeps.collector.location" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/location/src/main/kotlin/cool/linc/particeps/collector/location/LocationCollector.kt b/collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt similarity index 84% rename from collector/location/src/main/kotlin/cool/linc/particeps/collector/location/LocationCollector.kt rename to collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt index 2b84ef1..d42dba0 100644 --- a/collector/location/src/main/kotlin/cool/linc/particeps/collector/location/LocationCollector.kt +++ b/collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt @@ -1,28 +1,28 @@ -package cool.linc.particeps.collector.location +package cool.jacoblin.particeps.collector.location import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.location.Location import android.os.HandlerThread -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.LocationConfiguration -import cool.linc.particeps.core.definition.LocationPriority -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceCallbackBoundary -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult -import cool.linc.particeps.core.collector.completeSourceTeardown -import cool.linc.particeps.core.collector.registerSourceWithRollback +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.LocationPriority +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceCallbackBoundary +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.collector.completeSourceTeardown +import cool.jacoblin.particeps.core.collector.registerSourceWithRollback import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest diff --git a/collector/network-state/build.gradle.kts b/collector/network-state/build.gradle.kts index c2e7168..6a29d68 100644 --- a/collector/network-state/build.gradle.kts +++ b/collector/network-state/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.networkstate" + namespace = "cool.jacoblin.particeps.collector.networkstate" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/network-state/src/main/kotlin/cool/linc/particeps/collector/networkstate/NetworkStateCollector.kt b/collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt similarity index 82% rename from collector/network-state/src/main/kotlin/cool/linc/particeps/collector/networkstate/NetworkStateCollector.kt rename to collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt index 2f43922..6588202 100644 --- a/collector/network-state/src/main/kotlin/cool/linc/particeps/collector/networkstate/NetworkStateCollector.kt +++ b/collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt @@ -1,25 +1,25 @@ -package cool.linc.particeps.collector.networkstate +package cool.jacoblin.particeps.collector.networkstate import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.NetworkStateConfiguration -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceCallbackBoundary -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult -import cool.linc.particeps.core.collector.completeSourceTeardown -import cool.linc.particeps.core.collector.registerSourceWithRollback +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.NetworkStateConfiguration +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceCallbackBoundary +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.collector.completeSourceTeardown +import cool.jacoblin.particeps.core.collector.registerSourceWithRollback class NetworkStateCollectorPlugin( context: Context, diff --git a/collector/network-usage/build.gradle.kts b/collector/network-usage/build.gradle.kts index 09bb401..045fc35 100644 --- a/collector/network-usage/build.gradle.kts +++ b/collector/network-usage/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.networkusage" + namespace = "cool.jacoblin.particeps.collector.networkusage" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/network-usage/src/main/kotlin/cool/linc/particeps/collector/networkusage/NetworkUsageCollector.kt b/collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt similarity index 87% rename from collector/network-usage/src/main/kotlin/cool/linc/particeps/collector/networkusage/NetworkUsageCollector.kt rename to collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt index fcbb79d..bc325d4 100644 --- a/collector/network-usage/src/main/kotlin/cool/linc/particeps/collector/networkusage/NetworkUsageCollector.kt +++ b/collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt @@ -1,22 +1,22 @@ -package cool.linc.particeps.collector.networkusage +package cool.jacoblin.particeps.collector.networkusage import android.app.usage.NetworkStatsManager import android.content.Context -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.NetworkTransport -import cool.linc.particeps.core.definition.NetworkUsageConfiguration -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorHealth -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.collector.EmitResult +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.NetworkTransport +import cool.jacoblin.particeps.core.definition.NetworkUsageConfiguration +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorHealth +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.collector.EmitResult import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers diff --git a/collector/proximity/build.gradle.kts b/collector/proximity/build.gradle.kts index df15818..5c26799 100644 --- a/collector/proximity/build.gradle.kts +++ b/collector/proximity/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.proximity" + namespace = "cool.jacoblin.particeps.collector.proximity" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/proximity/src/main/kotlin/cool/linc/particeps/collector/proximity/ProximityCollector.kt b/collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt similarity index 86% rename from collector/proximity/src/main/kotlin/cool/linc/particeps/collector/proximity/ProximityCollector.kt rename to collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt index 9a264ac..fa5bc55 100644 --- a/collector/proximity/src/main/kotlin/cool/linc/particeps/collector/proximity/ProximityCollector.kt +++ b/collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt @@ -1,24 +1,24 @@ -package cool.linc.particeps.collector.proximity +package cool.jacoblin.particeps.collector.proximity import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.os.SystemClock -import cool.linc.particeps.collector.sensorcommon.AndroidSensorCollector -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.ProximityConfiguration -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.ProximityConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import kotlin.math.abs class ProximityCollectorPlugin(context: Context) : CollectorPlugin { diff --git a/collector/proximity/src/test/kotlin/cool/linc/particeps/collector/proximity/ProximityCollectorTest.kt b/collector/proximity/src/test/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollectorTest.kt similarity index 88% rename from collector/proximity/src/test/kotlin/cool/linc/particeps/collector/proximity/ProximityCollectorTest.kt rename to collector/proximity/src/test/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollectorTest.kt index 92fc555..3d94f0d 100644 --- a/collector/proximity/src/test/kotlin/cool/linc/particeps/collector/proximity/ProximityCollectorTest.kt +++ b/collector/proximity/src/test/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollectorTest.kt @@ -1,10 +1,10 @@ -package cool.linc.particeps.collector.proximity +package cool.jacoblin.particeps.collector.proximity -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.ProximityConfiguration -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.ProximityConfiguration +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/collector/sensor-common/build.gradle.kts b/collector/sensor-common/build.gradle.kts index b443c15..e45e9f4 100644 --- a/collector/sensor-common/build.gradle.kts +++ b/collector/sensor-common/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.sensorcommon" + namespace = "cool.jacoblin.particeps.collector.sensorcommon" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/sensor-common/src/main/kotlin/cool/linc/particeps/collector/sensorcommon/AndroidSensorCollector.kt b/collector/sensor-common/src/main/kotlin/cool/jacoblin/particeps/collector/sensorcommon/AndroidSensorCollector.kt similarity index 86% rename from collector/sensor-common/src/main/kotlin/cool/linc/particeps/collector/sensorcommon/AndroidSensorCollector.kt rename to collector/sensor-common/src/main/kotlin/cool/jacoblin/particeps/collector/sensorcommon/AndroidSensorCollector.kt index fb80c3b..8f31e6e 100644 --- a/collector/sensor-common/src/main/kotlin/cool/linc/particeps/collector/sensorcommon/AndroidSensorCollector.kt +++ b/collector/sensor-common/src/main/kotlin/cool/jacoblin/particeps/collector/sensorcommon/AndroidSensorCollector.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.collector.sensorcommon +package cool.jacoblin.particeps.collector.sensorcommon import android.content.Context import android.hardware.Sensor @@ -7,14 +7,14 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Handler import android.os.HandlerThread -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceCallbackBoundary -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult -import cool.linc.particeps.core.collector.completeSourceTeardown -import cool.linc.particeps.core.collector.registerSourceWithRollback -import cool.linc.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceCallbackBoundary +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.collector.completeSourceTeardown +import cool.jacoblin.particeps.core.collector.registerSourceWithRollback +import cool.jacoblin.particeps.core.model.EventDraft /** Common listener-thread ownership for raw Android sensor collectors. */ abstract class AndroidSensorCollector( diff --git a/collector/sensor-common/src/test/kotlin/cool/linc/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt b/collector/sensor-common/src/test/kotlin/cool/jacoblin/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt similarity index 92% rename from collector/sensor-common/src/test/kotlin/cool/linc/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt rename to collector/sensor-common/src/test/kotlin/cool/jacoblin/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt index 7531208..87d2e10 100644 --- a/collector/sensor-common/src/test/kotlin/cool/linc/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt +++ b/collector/sensor-common/src/test/kotlin/cool/jacoblin/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt @@ -1,11 +1,11 @@ -package cool.linc.particeps.collector.sensorcommon +package cool.jacoblin.particeps.collector.sensorcommon import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import cool.linc.particeps.core.collector.SourceCallbackBoundary -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.completeSourceTeardown -import cool.linc.particeps.core.collector.registerSourceWithRollback +import cool.jacoblin.particeps.core.collector.SourceCallbackBoundary +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.completeSourceTeardown +import cool.jacoblin.particeps.core.collector.registerSourceWithRollback import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/collector/temporal-context/build.gradle.kts b/collector/temporal-context/build.gradle.kts index 4dfd301..def2929 100644 --- a/collector/temporal-context/build.gradle.kts +++ b/collector/temporal-context/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.temporalcontext" + namespace = "cool.jacoblin.particeps.collector.temporalcontext" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/temporal-context/src/main/kotlin/cool/linc/particeps/collector/temporalcontext/TemporalContextCollector.kt b/collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt similarity index 86% rename from collector/temporal-context/src/main/kotlin/cool/linc/particeps/collector/temporalcontext/TemporalContextCollector.kt rename to collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt index 9116132..c8da814 100644 --- a/collector/temporal-context/src/main/kotlin/cool/linc/particeps/collector/temporalcontext/TemporalContextCollector.kt +++ b/collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.collector.temporalcontext +package cool.jacoblin.particeps.collector.temporalcontext import android.content.BroadcastReceiver import android.content.Context @@ -7,24 +7,24 @@ import android.content.IntentFilter import android.os.Handler import android.os.Looper import android.os.SystemClock -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.SerializedCallbackCollector -import cool.linc.particeps.core.collector.SourceRegistrationResult -import cool.linc.particeps.core.collector.SourceTeardownResult -import cool.linc.particeps.core.collector.completeSourceTeardown -import cool.linc.particeps.core.collector.registerSourceWithRollback -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.TemporalContextConfiguration -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector +import cool.jacoblin.particeps.core.collector.SourceRegistrationResult +import cool.jacoblin.particeps.core.collector.SourceTeardownResult +import cool.jacoblin.particeps.core.collector.completeSourceTeardown +import cool.jacoblin.particeps.core.collector.registerSourceWithRollback +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.TemporalContextConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import java.time.Instant import java.time.ZoneId import kotlinx.coroutines.Dispatchers diff --git a/collector/temporal-context/src/test/kotlin/cool/linc/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt b/collector/temporal-context/src/test/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt similarity index 90% rename from collector/temporal-context/src/test/kotlin/cool/linc/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt rename to collector/temporal-context/src/test/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt index 443a8bc..76dc7d7 100644 --- a/collector/temporal-context/src/test/kotlin/cool/linc/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt +++ b/collector/temporal-context/src/test/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollectorTest.kt @@ -1,10 +1,10 @@ -package cool.linc.particeps.collector.temporalcontext +package cool.jacoblin.particeps.collector.temporalcontext -import cool.linc.particeps.core.collector.LatestValueRateGate -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.TemporalContextConfiguration -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.collector.LatestValueRateGate +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.TemporalContextConfiguration +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import java.time.Instant import java.time.ZoneId import org.junit.Assert.assertEquals diff --git a/collector/usage-events/build.gradle.kts b/collector/usage-events/build.gradle.kts index 769c67d..8ebdeef 100644 --- a/collector/usage-events/build.gradle.kts +++ b/collector/usage-events/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.collector.usageevents" + namespace = "cool.jacoblin.particeps.collector.usageevents" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/collector/usage-events/src/main/kotlin/cool/linc/particeps/collector/usageevents/UsageEventsCollector.kt b/collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt similarity index 88% rename from collector/usage-events/src/main/kotlin/cool/linc/particeps/collector/usageevents/UsageEventsCollector.kt rename to collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt index f02ceda..99d6c51 100644 --- a/collector/usage-events/src/main/kotlin/cool/linc/particeps/collector/usageevents/UsageEventsCollector.kt +++ b/collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt @@ -1,22 +1,22 @@ -package cool.linc.particeps.collector.usageevents +package cool.jacoblin.particeps.collector.usageevents import android.app.usage.UsageEvents import android.app.usage.UsageStatsManager import android.content.Context -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.UsageEventsConfiguration -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorHealth -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.collector.EmitResult +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.UsageEventsConfiguration +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorHealth +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.collector.EmitResult import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers diff --git a/core/access/build.gradle.kts b/core/access/build.gradle.kts index 97b3f3c..cc82964 100644 --- a/core/access/build.gradle.kts +++ b/core/access/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.core.access" + namespace = "cool.jacoblin.particeps.core.access" compileSdk = 37 defaultConfig { minSdk = 34 } compileOptions { diff --git a/core/access/src/main/kotlin/cool/linc/particeps/core/access/AccessManager.kt b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt similarity index 94% rename from core/access/src/main/kotlin/cool/linc/particeps/core/access/AccessManager.kt rename to core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt index 5fa6737..bd49018 100644 --- a/core/access/src/main/kotlin/cool/linc/particeps/core/access/AccessManager.kt +++ b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.access +package cool.jacoblin.particeps.core.access import android.Manifest import android.app.AppOpsManager @@ -12,10 +12,10 @@ import android.net.Uri import android.os.Build import android.provider.Settings import android.view.inputmethod.InputMethodManager -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.AccessStatus -import cool.linc.particeps.core.collector.StudyAccessGateway +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.StudyAccessGateway class AccessManager( context: Context, diff --git a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/CollectorContracts.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt similarity index 97% rename from core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/CollectorContracts.kt rename to core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt index 28b57f7..15af5b7 100644 --- a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/CollectorContracts.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt @@ -1,8 +1,8 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.definition.CollectorConfiguration import com.google.gson.JsonParser import com.google.gson.JsonParseException import com.google.gson.Strictness @@ -291,7 +291,7 @@ data class CollectorContext( ) interface ResearchClocks { - fun now(): cool.linc.particeps.core.model.ResearchTime + fun now(): cool.jacoblin.particeps.core.model.ResearchTime } interface CollectorPlugin { diff --git a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/LatestValueRateGate.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/LatestValueRateGate.kt similarity index 98% rename from core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/LatestValueRateGate.kt rename to core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/LatestValueRateGate.kt index e032087..60f53d4 100644 --- a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/LatestValueRateGate.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/LatestValueRateGate.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector /** * Bounds an event-driven source while retaining its newest meaningful value. diff --git a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/ProtocolEventContracts.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventContracts.kt similarity index 99% rename from core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/ProtocolEventContracts.kt rename to core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventContracts.kt index a7f3bdb..7970207 100644 --- a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/ProtocolEventContracts.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventContracts.kt @@ -1,5 +1,5 @@ // Generated by tools/catalog.py from protocol/v1/collector-catalog.json. Do not edit. -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector object ProtocolEventContracts { val contracts: Map = mapOf( diff --git a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollector.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt similarity index 98% rename from core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollector.kt rename to core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt index e971bdc..d0cd1a0 100644 --- a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollector.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt @@ -1,6 +1,6 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector -import cool.linc.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.EventDraft import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers diff --git a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SourceLifecycle.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycle.kt similarity index 98% rename from core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SourceLifecycle.kt rename to core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycle.kt index ea1fdbf..81a1ed4 100644 --- a/core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SourceLifecycle.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycle.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext diff --git a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/EventFieldContractTest.kt b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/EventFieldContractTest.kt similarity index 95% rename from core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/EventFieldContractTest.kt rename to core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/EventFieldContractTest.kt index 90b0aea..63c9b08 100644 --- a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/EventFieldContractTest.kt +++ b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/EventFieldContractTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/LatestValueRateGateTest.kt b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/LatestValueRateGateTest.kt similarity index 98% rename from core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/LatestValueRateGateTest.kt rename to core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/LatestValueRateGateTest.kt index f83c8dd..bb8d25d 100644 --- a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/LatestValueRateGateTest.kt +++ b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/LatestValueRateGateTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector import org.junit.Assert.assertEquals import org.junit.Test diff --git a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/ProtocolEventSizeTest.kt b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventSizeTest.kt similarity index 90% rename from core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/ProtocolEventSizeTest.kt rename to core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventSizeTest.kt index 13b96e1..f68c6f5 100644 --- a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/ProtocolEventSizeTest.kt +++ b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventSizeTest.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.ResearchTime import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test diff --git a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollectorTest.kt b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollectorTest.kt similarity index 97% rename from core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollectorTest.kt rename to core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollectorTest.kt index 7e01d4c..b3b206c 100644 --- a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollectorTest.kt +++ b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollectorTest.kt @@ -1,8 +1,8 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SourceLifecycleTest.kt b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycleTest.kt similarity index 97% rename from core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SourceLifecycleTest.kt rename to core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycleTest.kt index 4b87700..0bc5be7 100644 --- a/core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SourceLifecycleTest.kt +++ b/core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycleTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.collector +package cool.jacoblin.particeps.core.collector import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/core/crypto/src/main/kotlin/cool/linc/particeps/core/crypto/Ed25519Crypto.kt b/core/crypto/src/main/kotlin/cool/jacoblin/particeps/core/crypto/Ed25519Crypto.kt similarity index 94% rename from core/crypto/src/main/kotlin/cool/linc/particeps/core/crypto/Ed25519Crypto.kt rename to core/crypto/src/main/kotlin/cool/jacoblin/particeps/core/crypto/Ed25519Crypto.kt index 67917b6..5047959 100644 --- a/core/crypto/src/main/kotlin/cool/linc/particeps/core/crypto/Ed25519Crypto.kt +++ b/core/crypto/src/main/kotlin/cool/jacoblin/particeps/core/crypto/Ed25519Crypto.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.crypto +package cool.jacoblin.particeps.core.crypto import com.google.crypto.tink.subtle.Ed25519Verify import java.security.GeneralSecurityException diff --git a/core/crypto/src/main/kotlin/cool/linc/particeps/core/crypto/HpkeCrypto.kt b/core/crypto/src/main/kotlin/cool/jacoblin/particeps/core/crypto/HpkeCrypto.kt similarity index 98% rename from core/crypto/src/main/kotlin/cool/linc/particeps/core/crypto/HpkeCrypto.kt rename to core/crypto/src/main/kotlin/cool/jacoblin/particeps/core/crypto/HpkeCrypto.kt index f9ca37e..8f8da06 100644 --- a/core/crypto/src/main/kotlin/cool/linc/particeps/core/crypto/HpkeCrypto.kt +++ b/core/crypto/src/main/kotlin/cool/jacoblin/particeps/core/crypto/HpkeCrypto.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.crypto +package cool.jacoblin.particeps.core.crypto import com.google.crypto.tink.AccessesPartialKey import com.google.crypto.tink.HybridDecrypt diff --git a/core/crypto/src/test/kotlin/cool/linc/particeps/core/crypto/Ed25519CryptoTest.kt b/core/crypto/src/test/kotlin/cool/jacoblin/particeps/core/crypto/Ed25519CryptoTest.kt similarity index 96% rename from core/crypto/src/test/kotlin/cool/linc/particeps/core/crypto/Ed25519CryptoTest.kt rename to core/crypto/src/test/kotlin/cool/jacoblin/particeps/core/crypto/Ed25519CryptoTest.kt index ade106b..ba75543 100644 --- a/core/crypto/src/test/kotlin/cool/linc/particeps/core/crypto/Ed25519CryptoTest.kt +++ b/core/crypto/src/test/kotlin/cool/jacoblin/particeps/core/crypto/Ed25519CryptoTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.crypto +package cool.jacoblin.particeps.core.crypto import java.security.KeyPairGenerator import java.security.Signature diff --git a/core/crypto/src/test/kotlin/cool/linc/particeps/core/crypto/HpkeCryptoTest.kt b/core/crypto/src/test/kotlin/cool/jacoblin/particeps/core/crypto/HpkeCryptoTest.kt similarity index 98% rename from core/crypto/src/test/kotlin/cool/linc/particeps/core/crypto/HpkeCryptoTest.kt rename to core/crypto/src/test/kotlin/cool/jacoblin/particeps/core/crypto/HpkeCryptoTest.kt index 304b15a..bd56c39 100644 --- a/core/crypto/src/test/kotlin/cool/linc/particeps/core/crypto/HpkeCryptoTest.kt +++ b/core/crypto/src/test/kotlin/cool/jacoblin/particeps/core/crypto/HpkeCryptoTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.crypto +package cool.jacoblin.particeps.core.crypto import java.security.GeneralSecurityException import org.junit.Assert.assertArrayEquals diff --git a/core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGate.kt b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt similarity index 95% rename from core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGate.kt rename to core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt index f9c75fd..30d0c0d 100644 --- a/core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGate.kt +++ b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt @@ -1,6 +1,6 @@ -package cool.linc.particeps.core.runtime +package cool.jacoblin.particeps.core.runtime -import cool.linc.particeps.core.collector.AdmissionToken +import cool.jacoblin.particeps.core.collector.AdmissionToken internal class EventAdmissionGate { private var epoch = 0L private var mode = Mode.CLOSED diff --git a/core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntime.kt b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt similarity index 94% rename from core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntime.kt rename to core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt index dfba764..9f4c137 100644 --- a/core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntime.kt +++ b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt @@ -1,35 +1,35 @@ -package cool.linc.particeps.core.runtime - -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AdmissionToken -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorHealth -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.CollectorRegistry -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.collector.EmitResult -import cool.linc.particeps.core.collector.EventSink -import cool.linc.particeps.core.collector.ResearchClocks -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.InterventionAction -import cool.linc.particeps.core.definition.MultipleChoiceQuestion -import cool.linc.particeps.core.definition.ScaleQuestion -import cool.linc.particeps.core.definition.ShortTextQuestion -import cool.linc.particeps.core.definition.SingleChoiceQuestion -import cool.linc.particeps.core.definition.SurveyAction -import cool.linc.particeps.core.definition.SurveyDefinition -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.ExperimentStateMachine -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.model.TransitionReason +package cool.jacoblin.particeps.core.runtime + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AdmissionToken +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorHealth +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.CollectorRegistry +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.collector.EmitResult +import cool.jacoblin.particeps.core.collector.EventSink +import cool.jacoblin.particeps.core.collector.ResearchClocks +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.InterventionAction +import cool.jacoblin.particeps.core.definition.MultipleChoiceQuestion +import cool.jacoblin.particeps.core.definition.ScaleQuestion +import cool.jacoblin.particeps.core.definition.ShortTextQuestion +import cool.jacoblin.particeps.core.definition.SingleChoiceQuestion +import cool.jacoblin.particeps.core.definition.SurveyAction +import cool.jacoblin.particeps.core.definition.SurveyDefinition +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.model.TransitionReason import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -649,7 +649,7 @@ class ExperimentRuntime( private suspend fun transitionTo( state: ExperimentState, reason: TransitionReason, - time: cool.linc.particeps.core.model.ResearchTime = clocks.now(), + time: cool.jacoblin.particeps.core.model.ResearchTime = clocks.now(), ) { metadataMutex.withLock { val updated = stateMachine.transition(requireMetadata(), state, reason, time) @@ -686,7 +686,7 @@ class ExperimentRuntime( private suspend fun expireOccurrence( metadata: StudyMetadata, occurrence: InterventionOccurrence, - now: cool.linc.particeps.core.model.ResearchTime, + now: cool.jacoblin.particeps.core.model.ResearchTime, ) { if (occurrence.state in setOf(OccurrenceState.EXPIRED, OccurrenceState.SURVEY_SUBMITTED)) return if (occurrence.state == OccurrenceState.OPENED && intervention(occurrence).action !is SurveyAction) return @@ -703,7 +703,7 @@ class ExperimentRuntime( metadataAfterState: StudyMetadata, occurrence: InterventionOccurrence, payloadType: String, - observedAt: cool.linc.particeps.core.model.ResearchTime, + observedAt: cool.jacoblin.particeps.core.model.ResearchTime, additionalFields: Map = emptyMap(), ) { val draft = EventDraft( @@ -771,7 +771,7 @@ class ExperimentRuntime( return encoded.takeIf { it.toByteArray().size <= MAXIMUM_SURVEY_ANSWERS_BYTES } } - private fun researchTimeJson(time: cool.linc.particeps.core.model.ResearchTime): String = + private fun researchTimeJson(time: cool.jacoblin.particeps.core.model.ResearchTime): String = "{\"wall_time_utc_millis\":${time.wallTimeUtcMillis},\"elapsed_realtime_nanos\":${time.elapsedRealtimeNanos}," + "\"boot_session_id\":${jsonString(time.bootSessionId)}}" @@ -807,7 +807,7 @@ class ExperimentRuntime( private data class CollectorEntry( val collector: Collector, - val configuration: cool.linc.particeps.core.definition.CollectorConfiguration, + val configuration: cool.jacoblin.particeps.core.definition.CollectorConfiguration, val plugin: CollectorPlugin, var hasStarted: Boolean = false, ) diff --git a/core/experiment-runtime/src/test/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGateTest.kt b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt similarity index 95% rename from core/experiment-runtime/src/test/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGateTest.kt rename to core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt index accfedf..5834cd0 100644 --- a/core/experiment-runtime/src/test/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGateTest.kt +++ b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.runtime +package cool.jacoblin.particeps.core.runtime import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/core/experiment-runtime/src/test/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntimeTest.kt b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt similarity index 93% rename from core/experiment-runtime/src/test/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntimeTest.kt rename to core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt index 798793f..3e4f47f 100644 --- a/core/experiment-runtime/src/test/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntimeTest.kt +++ b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt @@ -1,47 +1,47 @@ -package cool.linc.particeps.core.runtime - -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StorageUsage -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorEventContract -import cool.linc.particeps.core.collector.EventFieldContract -import cool.linc.particeps.core.collector.EventFieldType -import cool.linc.particeps.core.collector.EventPayloadContract -import cool.linc.particeps.core.collector.CollectorHealth -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.CollectorRegistry -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.collector.EmitResult -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.ChoiceOption -import cool.linc.particeps.core.definition.InterventionConfiguration -import cool.linc.particeps.core.definition.InterventionTrigger -import cool.linc.particeps.core.definition.LocalizedText -import cool.linc.particeps.core.definition.MultipleChoiceQuestion -import cool.linc.particeps.core.definition.OneTimeSchedule -import cool.linc.particeps.core.definition.RelativeClock -import cool.linc.particeps.core.definition.ScaleQuestion -import cool.linc.particeps.core.definition.ShortTextQuestion -import cool.linc.particeps.core.definition.SingleChoiceQuestion -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ResearchClocks -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.SurveyAction -import cool.linc.particeps.core.definition.SurveyDefinition +package cool.jacoblin.particeps.core.runtime + +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StorageUsage +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorEventContract +import cool.jacoblin.particeps.core.collector.EventFieldContract +import cool.jacoblin.particeps.core.collector.EventFieldType +import cool.jacoblin.particeps.core.collector.EventPayloadContract +import cool.jacoblin.particeps.core.collector.CollectorHealth +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.CollectorRegistry +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.collector.EmitResult +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.ChoiceOption +import cool.jacoblin.particeps.core.definition.InterventionConfiguration +import cool.jacoblin.particeps.core.definition.InterventionTrigger +import cool.jacoblin.particeps.core.definition.LocalizedText +import cool.jacoblin.particeps.core.definition.MultipleChoiceQuestion +import cool.jacoblin.particeps.core.definition.OneTimeSchedule +import cool.jacoblin.particeps.core.definition.RelativeClock +import cool.jacoblin.particeps.core.definition.ScaleQuestion +import cool.jacoblin.particeps.core.definition.ShortTextQuestion +import cool.jacoblin.particeps.core.definition.SingleChoiceQuestion +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ResearchClocks +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.SurveyAction +import cool.jacoblin.particeps.core.definition.SurveyDefinition import java.time.Instant import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll diff --git a/core/export/src/main/kotlin/cool/linc/particeps/core/export/CanonicalJsonWriter.kt b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/CanonicalJsonWriter.kt similarity index 99% rename from core/export/src/main/kotlin/cool/linc/particeps/core/export/CanonicalJsonWriter.kt rename to core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/CanonicalJsonWriter.kt index 0060bfa..d3a9bb4 100644 --- a/core/export/src/main/kotlin/cool/linc/particeps/core/export/CanonicalJsonWriter.kt +++ b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/CanonicalJsonWriter.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.export +package cool.jacoblin.particeps.core.export import java.io.Closeable import java.io.OutputStream diff --git a/core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchBundleVerifier.kt b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt similarity index 96% rename from core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchBundleVerifier.kt rename to core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt index 6de24a5..1cfdd9d 100644 --- a/core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchBundleVerifier.kt +++ b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt @@ -1,21 +1,21 @@ -package cool.linc.particeps.core.export +package cool.jacoblin.particeps.core.export import com.google.gson.Strictness import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonToken -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.ExperimentStateMachine -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.TransitionReason -import cool.linc.particeps.core.protocol.ConfigurationVerifier -import cool.linc.particeps.core.protocol.SignedConfigurationCodec -import cool.linc.particeps.core.protocol.SignedConfigurationEnvelope -import cool.linc.particeps.core.protocol.VerifiedConfiguration +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.TransitionReason +import cool.jacoblin.particeps.core.protocol.ConfigurationVerifier +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.core.protocol.SignedConfigurationEnvelope +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.io.ByteArrayInputStream import java.io.InputStream import java.io.InputStreamReader diff --git a/core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchExport.kt b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt similarity index 96% rename from core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchExport.kt rename to core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt index 124cd12..58d1043 100644 --- a/core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchExport.kt +++ b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt @@ -1,14 +1,14 @@ -package cool.linc.particeps.core.export - -import cool.linc.particeps.core.crypto.HpkeCrypto -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.protocol.VerifiedConfiguration +package cool.jacoblin.particeps.core.export + +import cool.jacoblin.particeps.core.crypto.HpkeCrypto +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream @@ -356,7 +356,7 @@ object ResearchExport { "\"configuration_sha256\":\"$configurationSha256\",\"researcher_key_id\":\"$researcherKeyId\"}" ).toByteArray(Charsets.UTF_8) - private fun cool.linc.particeps.core.definition.ExportConfiguration.hpkePublicKeyBytes(): ByteArray = + private fun cool.jacoblin.particeps.core.definition.ExportConfiguration.hpkePublicKeyBytes(): ByteArray = ProtocolBase64Url.decodeExact(hpkePublicKey, HpkeCrypto.RAW_KEY_BYTES, "X25519 public key") private fun ByteBuffer.putUuid(uuid: UUID): ByteBuffer = putLong(uuid.mostSignificantBits) diff --git a/core/export/src/main/kotlin/cool/linc/particeps/core/export/UploadReceiptCodec.kt b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/UploadReceiptCodec.kt similarity index 98% rename from core/export/src/main/kotlin/cool/linc/particeps/core/export/UploadReceiptCodec.kt rename to core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/UploadReceiptCodec.kt index 13740db..63913d7 100644 --- a/core/export/src/main/kotlin/cool/linc/particeps/core/export/UploadReceiptCodec.kt +++ b/core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/UploadReceiptCodec.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.export +package cool.jacoblin.particeps.core.export import com.google.gson.JsonElement import com.google.gson.JsonObject diff --git a/core/export/src/test/kotlin/cool/linc/particeps/core/export/ResearchExportTest.kt b/core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt similarity index 93% rename from core/export/src/test/kotlin/cool/linc/particeps/core/export/ResearchExportTest.kt rename to core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt index 2b15363..7b4da77 100644 --- a/core/export/src/test/kotlin/cool/linc/particeps/core/export/ResearchExportTest.kt +++ b/core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt @@ -1,20 +1,20 @@ -package cool.linc.particeps.core.export +package cool.jacoblin.particeps.core.export import com.google.gson.JsonParser -import cool.linc.particeps.core.crypto.HpkeCrypto -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StorageUsage -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.protocol.VerifiedConfiguration +import cool.jacoblin.particeps.core.crypto.HpkeCrypto +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StorageUsage +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.time.Instant @@ -292,7 +292,7 @@ class ResearchExportTest { ) private data class Fixture( - val hpke: cool.linc.particeps.core.crypto.HpkeKeyPair, + val hpke: cool.jacoblin.particeps.core.crypto.HpkeKeyPair, val configuration: StudyConfiguration, val verified: VerifiedConfiguration, ) diff --git a/core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentModels.kt b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt similarity index 98% rename from core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentModels.kt rename to core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt index 763817f..966eed3 100644 --- a/core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentModels.kt +++ b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.model +package cool.jacoblin.particeps.core.model enum class ExperimentState { IMPORTED, diff --git a/core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentStateMachine.kt b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentStateMachine.kt similarity index 98% rename from core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentStateMachine.kt rename to core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentStateMachine.kt index 8f4742e..03d8922 100644 --- a/core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentStateMachine.kt +++ b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentStateMachine.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.model +package cool.jacoblin.particeps.core.model class ExperimentStateMachine { fun canTransition( diff --git a/core/model/src/main/kotlin/cool/linc/particeps/core/model/StudyData.kt b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt similarity index 99% rename from core/model/src/main/kotlin/cool/linc/particeps/core/model/StudyData.kt rename to core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt index d6b9e0b..d0625a9 100644 --- a/core/model/src/main/kotlin/cool/linc/particeps/core/model/StudyData.kt +++ b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.model +package cool.jacoblin.particeps.core.model import java.util.UUID diff --git a/core/model/src/test/kotlin/cool/linc/particeps/core/model/ExperimentStateMachineTest.kt b/core/model/src/test/kotlin/cool/jacoblin/particeps/core/model/ExperimentStateMachineTest.kt similarity index 98% rename from core/model/src/test/kotlin/cool/linc/particeps/core/model/ExperimentStateMachineTest.kt rename to core/model/src/test/kotlin/cool/jacoblin/particeps/core/model/ExperimentStateMachineTest.kt index 1498cd7..7803e4b 100644 --- a/core/model/src/test/kotlin/cool/linc/particeps/core/model/ExperimentStateMachineTest.kt +++ b/core/model/src/test/kotlin/cool/jacoblin/particeps/core/model/ExperimentStateMachineTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.model +package cool.jacoblin.particeps.core.model import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/JoinLink.kt b/core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt similarity index 99% rename from core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/JoinLink.kt rename to core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt index e64bc33..4b862bf 100644 --- a/core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/JoinLink.kt +++ b/core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.protocol +package cool.jacoblin.particeps.core.protocol import java.io.ByteArrayOutputStream import java.net.URI diff --git a/core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/SignedConfiguration.kt b/core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt similarity index 96% rename from core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/SignedConfiguration.kt rename to core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt index d056b7e..ead44bb 100644 --- a/core/protocol/src/main/kotlin/cool/linc/particeps/core/protocol/SignedConfiguration.kt +++ b/core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt @@ -1,9 +1,9 @@ -package cool.linc.particeps.core.protocol +package cool.jacoblin.particeps.core.protocol -import cool.linc.particeps.core.crypto.Ed25519Crypto -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.crypto.Ed25519Crypto +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction import java.security.MessageDigest diff --git a/core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/ConfigurationProtocolTest.kt b/core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/ConfigurationProtocolTest.kt similarity index 93% rename from core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/ConfigurationProtocolTest.kt rename to core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/ConfigurationProtocolTest.kt index 602129c..567f4c5 100644 --- a/core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/ConfigurationProtocolTest.kt +++ b/core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/ConfigurationProtocolTest.kt @@ -1,14 +1,14 @@ -package cool.linc.particeps.core.protocol - -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.LocationConfiguration -import cool.linc.particeps.core.definition.LocationPriority -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.definition.UploadConfiguration +package cool.jacoblin.particeps.core.protocol + +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.LocationPriority +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.definition.UploadConfiguration import java.nio.ByteBuffer import java.security.KeyPair import java.security.KeyPairGenerator diff --git a/core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/JoinLinkTest.kt b/core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/JoinLinkTest.kt similarity index 98% rename from core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/JoinLinkTest.kt rename to core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/JoinLinkTest.kt index bff65b2..c671770 100644 --- a/core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/JoinLinkTest.kt +++ b/core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/JoinLinkTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.protocol +package cool.jacoblin.particeps.core.protocol import java.net.URI import org.junit.Assert.assertEquals diff --git a/core/storage/build.gradle.kts b/core/storage/build.gradle.kts index 0c8e63d..6293bca 100644 --- a/core/storage/build.gradle.kts +++ b/core/storage/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "cool.linc.particeps.core.storage" + namespace = "cool.jacoblin.particeps.core.storage" compileSdk = 37 defaultConfig { diff --git a/core/storage/src/androidTest/kotlin/cool/linc/particeps/core/storage/EncryptedExperimentStoreTest.kt b/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt similarity index 97% rename from core/storage/src/androidTest/kotlin/cool/linc/particeps/core/storage/EncryptedExperimentStoreTest.kt rename to core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt index 6a24146..ea5193d 100644 --- a/core/storage/src/androidTest/kotlin/cool/linc/particeps/core/storage/EncryptedExperimentStoreTest.kt +++ b/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt @@ -1,11 +1,11 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime import java.io.File import kotlinx.coroutines.runBlocking import org.junit.After @@ -213,7 +213,7 @@ class EncryptedExperimentStoreTest { private fun event( sequence: Long, - fields: Map = mapOf("activity_class" to "cool.linc.Demo"), + fields: Map = mapOf("activity_class" to "cool.jacoblin.Demo"), ) = RecordedEvent( sequenceNumber = sequence, collectorId = "app_lifecycle.v1", diff --git a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/AppendTransactionRecovery.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt similarity index 96% rename from core/storage/src/main/kotlin/cool/linc/particeps/core/storage/AppendTransactionRecovery.kt rename to core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt index 1593b0a..c831054 100644 --- a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/AppendTransactionRecovery.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt @@ -1,7 +1,7 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.StudyMetadata internal data class AppendRecoveryResult( val metadata: StudyMetadata, diff --git a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EncryptedActiveStudyStore.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt similarity index 97% rename from core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EncryptedActiveStudyStore.kt rename to core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt index 4d24200..fdabd3e 100644 --- a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EncryptedActiveStudyStore.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt @@ -1,11 +1,11 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.AtomicFile -import cool.linc.particeps.core.protocol.ActiveStudyRecord -import cool.linc.particeps.core.protocol.ActiveStudyStore +import cool.jacoblin.particeps.core.protocol.ActiveStudyRecord +import cool.jacoblin.particeps.core.protocol.ActiveStudyStore import java.nio.ByteBuffer import java.security.KeyStore import javax.crypto.Cipher diff --git a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EncryptedExperimentStore.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt similarity index 99% rename from core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EncryptedExperimentStore.kt rename to core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt index b20133f..380dee5 100644 --- a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EncryptedExperimentStore.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt @@ -1,13 +1,13 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.AtomicFile -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.StorageUsage -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.StorageUsage +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore import java.io.File import java.io.RandomAccessFile import java.nio.ByteBuffer diff --git a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EvictionPlanner.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EvictionPlanner.kt similarity index 98% rename from core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EvictionPlanner.kt rename to core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EvictionPlanner.kt index a5370e4..49fa476 100644 --- a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/EvictionPlanner.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EvictionPlanner.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage /** * One event segment as the planner sees it: its index, the sequence of its first frame, and its diff --git a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/StudyDataJsonCodec.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/StudyDataJsonCodec.kt similarity index 95% rename from core/storage/src/main/kotlin/cool/linc/particeps/core/storage/StudyDataJsonCodec.kt rename to core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/StudyDataJsonCodec.kt index 160f065..3ad4b38 100644 --- a/core/storage/src/main/kotlin/cool/linc/particeps/core/storage/StudyDataJsonCodec.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/StudyDataJsonCodec.kt @@ -1,13 +1,13 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.ExperimentTransition -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.TransitionReason +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentTransition +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason import org.json.JSONArray import org.json.JSONObject diff --git a/core/storage/src/test/kotlin/cool/linc/particeps/core/storage/AppendTransactionRecoveryTest.kt b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt similarity index 92% rename from core/storage/src/test/kotlin/cool/linc/particeps/core/storage/AppendTransactionRecoveryTest.kt rename to core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt index c5c1db5..ea1d3ec 100644 --- a/core/storage/src/test/kotlin/cool/linc/particeps/core/storage/AppendTransactionRecoveryTest.kt +++ b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt @@ -1,10 +1,10 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows diff --git a/core/storage/src/test/kotlin/cool/linc/particeps/core/storage/EvictionPlannerTest.kt b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/EvictionPlannerTest.kt similarity index 98% rename from core/storage/src/test/kotlin/cool/linc/particeps/core/storage/EvictionPlannerTest.kt rename to core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/EvictionPlannerTest.kt index 2dfec7c..70e5714 100644 --- a/core/storage/src/test/kotlin/cool/linc/particeps/core/storage/EvictionPlannerTest.kt +++ b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/EvictionPlannerTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/core/storage/src/test/kotlin/cool/linc/particeps/core/storage/StudyDataReconciliationTest.kt b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt similarity index 89% rename from core/storage/src/test/kotlin/cool/linc/particeps/core/storage/StudyDataReconciliationTest.kt rename to core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt index abf10cb..d11fa89 100644 --- a/core/storage/src/test/kotlin/cool/linc/particeps/core/storage/StudyDataReconciliationTest.kt +++ b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt @@ -1,8 +1,8 @@ -package cool.linc.particeps.core.storage +package cool.jacoblin.particeps.core.storage -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue diff --git a/core/study-application/src/main/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlanner.kt b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt similarity index 94% rename from core/study-application/src/main/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlanner.kt rename to core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt index 65e7e9d..68d8fcc 100644 --- a/core/study-application/src/main/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlanner.kt +++ b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt @@ -1,18 +1,18 @@ -package cool.linc.particeps.core.application +package cool.jacoblin.particeps.core.application -import cool.linc.particeps.core.definition.DailyLocalSchedule -import cool.linc.particeps.core.definition.IntervalSchedule -import cool.linc.particeps.core.definition.OneTimeSchedule -import cool.linc.particeps.core.definition.RandomWindowSchedule -import cool.linc.particeps.core.definition.RelativeClock -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.InterventionTrigger -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.TransitionReason +import cool.jacoblin.particeps.core.definition.DailyLocalSchedule +import cool.jacoblin.particeps.core.definition.IntervalSchedule +import cool.jacoblin.particeps.core.definition.OneTimeSchedule +import cool.jacoblin.particeps.core.definition.RandomWindowSchedule +import cool.jacoblin.particeps.core.definition.RelativeClock +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.InterventionTrigger +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.security.SecureRandom diff --git a/core/study-application/src/main/kotlin/cool/linc/particeps/core/application/StudyApplication.kt b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt similarity index 95% rename from core/study-application/src/main/kotlin/cool/linc/particeps/core/application/StudyApplication.kt rename to core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt index 909ffc7..cc404ab 100644 --- a/core/study-application/src/main/kotlin/cool/linc/particeps/core/application/StudyApplication.kt +++ b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt @@ -1,31 +1,31 @@ -package cool.linc.particeps.core.application - -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.AccessStatus -import cool.linc.particeps.core.collector.CollectorRegistry -import cool.linc.particeps.core.collector.StudyAccessGateway -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.SurveyAction -import cool.linc.particeps.core.export.ExportReceipt -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.protocol.ActiveStudyStore -import cool.linc.particeps.core.protocol.ActiveStudyRecord -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.VerifiedConfiguration -import cool.linc.particeps.core.runtime.CommandResult -import cool.linc.particeps.core.runtime.ExperimentRuntime -import cool.linc.particeps.core.runtime.OccurrenceClaimResult -import cool.linc.particeps.core.runtime.OccurrenceDispatch -import cool.linc.particeps.core.runtime.OccurrenceExpiryResult -import cool.linc.particeps.core.runtime.RuntimeSnapshot -import cool.linc.particeps.core.runtime.SurveyAnswer -import cool.linc.particeps.core.runtime.SurveySubmissionResult +package cool.jacoblin.particeps.core.application + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.CollectorRegistry +import cool.jacoblin.particeps.core.collector.StudyAccessGateway +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.SurveyAction +import cool.jacoblin.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.protocol.ActiveStudyStore +import cool.jacoblin.particeps.core.protocol.ActiveStudyRecord +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration +import cool.jacoblin.particeps.core.runtime.CommandResult +import cool.jacoblin.particeps.core.runtime.ExperimentRuntime +import cool.jacoblin.particeps.core.runtime.OccurrenceClaimResult +import cool.jacoblin.particeps.core.runtime.OccurrenceDispatch +import cool.jacoblin.particeps.core.runtime.OccurrenceExpiryResult +import cool.jacoblin.particeps.core.runtime.RuntimeSnapshot +import cool.jacoblin.particeps.core.runtime.SurveyAnswer +import cool.jacoblin.particeps.core.runtime.SurveySubmissionResult import java.io.OutputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope diff --git a/core/study-application/src/test/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlannerTest.kt b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt similarity index 93% rename from core/study-application/src/test/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlannerTest.kt rename to core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt index 2cb5af5..6a3c685 100644 --- a/core/study-application/src/test/kotlin/cool/linc/particeps/core/application/InterventionSchedulePlannerTest.kt +++ b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt @@ -1,25 +1,25 @@ -package cool.linc.particeps.core.application - -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.DailyLocalSchedule -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.InterventionConfiguration -import cool.linc.particeps.core.definition.InterventionSchedule -import cool.linc.particeps.core.definition.InterventionTrigger -import cool.linc.particeps.core.definition.IntervalSchedule -import cool.linc.particeps.core.definition.NotificationAction -import cool.linc.particeps.core.definition.OneTimeSchedule -import cool.linc.particeps.core.definition.RelativeClock -import cool.linc.particeps.core.definition.RandomLocalWindow -import cool.linc.particeps.core.definition.RandomWindowSchedule -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.ExperimentTransition -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.TransitionReason +package cool.jacoblin.particeps.core.application + +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.DailyLocalSchedule +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.InterventionConfiguration +import cool.jacoblin.particeps.core.definition.InterventionSchedule +import cool.jacoblin.particeps.core.definition.InterventionTrigger +import cool.jacoblin.particeps.core.definition.IntervalSchedule +import cool.jacoblin.particeps.core.definition.NotificationAction +import cool.jacoblin.particeps.core.definition.OneTimeSchedule +import cool.jacoblin.particeps.core.definition.RelativeClock +import cool.jacoblin.particeps.core.definition.RandomLocalWindow +import cool.jacoblin.particeps.core.definition.RandomWindowSchedule +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentTransition +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason import java.time.Instant import java.time.LocalDate import java.time.ZoneId diff --git a/core/study-application/src/test/kotlin/cool/linc/particeps/core/application/StudySessionManagerTest.kt b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt similarity index 93% rename from core/study-application/src/test/kotlin/cool/linc/particeps/core/application/StudySessionManagerTest.kt rename to core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt index b063c0e..2ac2e0c 100644 --- a/core/study-application/src/test/kotlin/cool/linc/particeps/core/application/StudySessionManagerTest.kt +++ b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt @@ -1,55 +1,55 @@ -package cool.linc.particeps.core.application - -import cool.linc.particeps.core.collector.AccessKind -import cool.linc.particeps.core.collector.AccessRequirement -import cool.linc.particeps.core.collector.AccessStatus -import cool.linc.particeps.core.collector.Collector -import cool.linc.particeps.core.collector.CollectorContext -import cool.linc.particeps.core.collector.CollectorDescriptor -import cool.linc.particeps.core.collector.CollectorHealth -import cool.linc.particeps.core.collector.CollectorPlugin -import cool.linc.particeps.core.collector.CollectorRegistry -import cool.linc.particeps.core.collector.CollectorStatus -import cool.linc.particeps.core.collector.PrivacyClass -import cool.linc.particeps.core.collector.ProtocolEventContracts -import cool.linc.particeps.core.collector.ResearchClocks -import cool.linc.particeps.core.collector.StudyAccessGateway -import cool.linc.particeps.core.definition.AppLifecycleConfiguration -import cool.linc.particeps.core.definition.CollectorConfiguration -import cool.linc.particeps.core.definition.ExportConfiguration -import cool.linc.particeps.core.definition.InterventionConfiguration -import cool.linc.particeps.core.definition.InterventionTrigger -import cool.linc.particeps.core.definition.IntervalSchedule -import cool.linc.particeps.core.definition.LocalizedText -import cool.linc.particeps.core.definition.NotificationAction -import cool.linc.particeps.core.definition.OneTimeSchedule -import cool.linc.particeps.core.definition.RelativeClock -import cool.linc.particeps.core.definition.SignerIdentity -import cool.linc.particeps.core.definition.ShortTextQuestion -import cool.linc.particeps.core.definition.StudyConfiguration -import cool.linc.particeps.core.definition.SurveyAction -import cool.linc.particeps.core.definition.SurveyDefinition -import cool.linc.particeps.core.definition.UploadConfiguration -import cool.linc.particeps.core.export.ExportReceipt -import cool.linc.particeps.core.model.EventDraft -import cool.linc.particeps.core.model.ExperimentState -import cool.linc.particeps.core.model.ExperimentTransition -import cool.linc.particeps.core.model.InterventionOccurrence -import cool.linc.particeps.core.model.OccurrenceState -import cool.linc.particeps.core.model.RecordedEvent -import cool.linc.particeps.core.model.ResearchTime -import cool.linc.particeps.core.model.StorageUsage -import cool.linc.particeps.core.model.StudyMetadata -import cool.linc.particeps.core.model.StudyStore -import cool.linc.particeps.core.model.TransitionReason -import cool.linc.particeps.core.runtime.CommandResult -import cool.linc.particeps.core.runtime.ExperimentRuntime -import cool.linc.particeps.core.runtime.OccurrenceClaimResult -import cool.linc.particeps.core.runtime.OccurrenceExpiryResult -import cool.linc.particeps.core.protocol.ActiveStudyStore -import cool.linc.particeps.core.protocol.ActiveStudyRecord -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.VerifiedConfiguration +package cool.jacoblin.particeps.core.application + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.Collector +import cool.jacoblin.particeps.core.collector.CollectorContext +import cool.jacoblin.particeps.core.collector.CollectorDescriptor +import cool.jacoblin.particeps.core.collector.CollectorHealth +import cool.jacoblin.particeps.core.collector.CollectorPlugin +import cool.jacoblin.particeps.core.collector.CollectorRegistry +import cool.jacoblin.particeps.core.collector.CollectorStatus +import cool.jacoblin.particeps.core.collector.PrivacyClass +import cool.jacoblin.particeps.core.collector.ProtocolEventContracts +import cool.jacoblin.particeps.core.collector.ResearchClocks +import cool.jacoblin.particeps.core.collector.StudyAccessGateway +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.InterventionConfiguration +import cool.jacoblin.particeps.core.definition.InterventionTrigger +import cool.jacoblin.particeps.core.definition.IntervalSchedule +import cool.jacoblin.particeps.core.definition.LocalizedText +import cool.jacoblin.particeps.core.definition.NotificationAction +import cool.jacoblin.particeps.core.definition.OneTimeSchedule +import cool.jacoblin.particeps.core.definition.RelativeClock +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.ShortTextQuestion +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.SurveyAction +import cool.jacoblin.particeps.core.definition.SurveyDefinition +import cool.jacoblin.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.export.ExportReceipt +import cool.jacoblin.particeps.core.model.EventDraft +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentTransition +import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.OccurrenceState +import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StorageUsage +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.model.TransitionReason +import cool.jacoblin.particeps.core.runtime.CommandResult +import cool.jacoblin.particeps.core.runtime.ExperimentRuntime +import cool.jacoblin.particeps.core.runtime.OccurrenceClaimResult +import cool.jacoblin.particeps.core.runtime.OccurrenceExpiryResult +import cool.jacoblin.particeps.core.protocol.ActiveStudyStore +import cool.jacoblin.particeps.core.protocol.ActiveStudyRecord +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.VerifiedConfiguration import java.io.OutputStream import java.net.URI import java.security.MessageDigest diff --git a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/ProtocolBase64Url.kt b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/ProtocolBase64Url.kt similarity index 95% rename from core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/ProtocolBase64Url.kt rename to core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/ProtocolBase64Url.kt index 554af4f..8009305 100644 --- a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/ProtocolBase64Url.kt +++ b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/ProtocolBase64Url.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.definition +package cool.jacoblin.particeps.core.definition import java.util.Base64 diff --git a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/ProtocolCanonicalJson.kt b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/ProtocolCanonicalJson.kt similarity index 99% rename from core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/ProtocolCanonicalJson.kt rename to core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/ProtocolCanonicalJson.kt index 010e923..bdd9e8c 100644 --- a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/ProtocolCanonicalJson.kt +++ b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/ProtocolCanonicalJson.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.definition +package cool.jacoblin.particeps.core.definition import com.google.gson.JsonArray import com.google.gson.JsonElement diff --git a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt similarity index 99% rename from core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt rename to core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt index 805a24f..174f0e7 100644 --- a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt +++ b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.definition +package cool.jacoblin.particeps.core.definition import java.time.Instant diff --git a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt similarity index 99% rename from core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt rename to core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt index 807004c..5c219c2 100644 --- a/core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt +++ b/core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.definition +package cool.jacoblin.particeps.core.definition import com.google.gson.JsonArray import com.google.gson.JsonElement diff --git a/core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/NetworkUsageConfigurationTest.kt b/core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/NetworkUsageConfigurationTest.kt similarity index 95% rename from core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/NetworkUsageConfigurationTest.kt rename to core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/NetworkUsageConfigurationTest.kt index 2379972..1f0494a 100644 --- a/core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/NetworkUsageConfigurationTest.kt +++ b/core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/NetworkUsageConfigurationTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.definition +package cool.jacoblin.particeps.core.definition import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows diff --git a/core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/P2ConfigurationTest.kt b/core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/P2ConfigurationTest.kt similarity index 99% rename from core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/P2ConfigurationTest.kt rename to core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/P2ConfigurationTest.kt index d107247..ae23955 100644 --- a/core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/P2ConfigurationTest.kt +++ b/core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/P2ConfigurationTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.core.definition +package cool.jacoblin.particeps.core.definition import java.time.Instant import org.junit.Assert.assertEquals diff --git a/docs/component-boundaries.md b/docs/component-boundaries.md index c2aafbb..4d065c9 100644 --- a/docs/component-boundaries.md +++ b/docs/component-boundaries.md @@ -9,9 +9,9 @@ New contributors should read this with the [normative Protocol v1 contract](../p the [collector catalog](../protocol/v1/collector-catalog.json), the [P0–P2 implementation contract](p0-p2-implementation-contract.md), and [`assurance`](../assurance/README.md). The concrete upload seam is deliberately short: -[StudyUploader](../core/study-application/src/main/kotlin/cool/linc/particeps/core/application/StudyApplication.kt), -[FileUploadOutbox](../app/src/main/kotlin/cool/linc/particeps/platform/FileUploadOutbox.kt), -[OkHttpStudyUploader](../app/src/main/kotlin/cool/linc/particeps/platform/OkHttpStudyUploader.kt), +[StudyUploader](../core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt), +[FileUploadOutbox](../app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), +[OkHttpStudyUploader](../app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt), and their neighbouring tests. ```text diff --git a/docs/data-collector-implementation-guide.md b/docs/data-collector-implementation-guide.md index af9a03c..bfc2be3 100644 --- a/docs/data-collector-implementation-guide.md +++ b/docs/data-collector-implementation-guide.md @@ -10,7 +10,7 @@ covers only the collector side of that boundary. The [normative Protocol v1 cont defines the enclosing configuration and bundle. Its machine-readable schema source is the [Protocol v1 collector catalog](../protocol/v1/collector-catalog.json); the generated Kotlin projection is -[`ProtocolEventContracts.kt`](../core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/ProtocolEventContracts.kt). +[`ProtocolEventContracts.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventContracts.kt). Read the [Collector capability policy](../assurance/README.md) before adding a module; CI enforces its source, bytecode, and dependency boundaries. @@ -32,7 +32,7 @@ codec's `when` over collector IDs is the allowlist. ## 2. The contract Every type below is declared in one file: -[`core/collector-api/.../CollectorContracts.kt`](../core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/CollectorContracts.kt). +[`core/collector-api/.../CollectorContracts.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt). The only other file in `:core:collector-api` is the shared base class covered in [section 6](#6-lifecycle). @@ -115,7 +115,7 @@ machine, no scheduler, no exporter, and no `Activity`. That narrowness is the bo see [section 3](#3-invariants-a-collector-must-hold). `ResearchTime` carries three readings taken together -([`core/model/.../ExperimentModels.kt`](../core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentModels.kt)): +([`core/model/.../ExperimentModels.kt`](../core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt)): ```kotlin data class ResearchTime( @@ -155,7 +155,7 @@ interface EventSink { `AdmissionToken` is a marker interface with no members. The only implementation is `EventAdmissionGate.EpochToken`, which is `private` inside -[`core/experiment-runtime/.../EventAdmissionGate.kt`](../core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/EventAdmissionGate.kt). +[`core/experiment-runtime/.../EventAdmissionGate.kt`](../core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt). A collector can write `object : AdmissionToken {}`, but the gate's `epoch()` extension maps any foreign implementation to `Long.MIN_VALUE`, which never equals a live epoch, so the event is rejected. Forging a token is possible; forging an accepted token is not. @@ -249,7 +249,7 @@ FOREGROUND_SERVICE_SPECIAL_USE INTERNET PACKAGE_USAGE_STATS POST_NOTIFICATIONS RECEIVE_BOOT_COMPLETED WAKE_LOCK ``` -plus the signature-level `cool.linc.particeps.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` +plus the signature-level `cool.jacoblin.particeps.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` that AndroidX contributes. `INTERNET` belongs to the study application layer's upload worker; its presence no longer tells you whether any given study transmits, which is why bytecode and dependency policy are required in addition to a permission diff. @@ -301,7 +301,7 @@ fun pluginFor(configuration: CollectorConfiguration): CollectorPlugin = ### Strict configuration decoding Configuration decoding lives in -[`StudyConfigurationCodec`](../core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt). +[`StudyConfigurationCodec`](../core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt). It is strict in a specific, checkable way: - `requireExactKeys` demands the exact key set. Unknown keys, missing keys, and renamed keys @@ -317,7 +317,7 @@ equally to a configuration built in a test. ## 6. Lifecycle The base class for callback-driven collectors is -[`SerializedCallbackCollector`](../core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollector.kt). +[`SerializedCallbackCollector`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt). It marks all four lifecycle methods `final` and leaves you two: ```kotlin @@ -412,7 +412,7 @@ capture { ### What `EventDraft` allows -From [`core/model/.../ExperimentModels.kt`](../core/model/src/main/kotlin/cool/linc/particeps/core/model/ExperimentModels.kt): +From [`core/model/.../ExperimentModels.kt`](../core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt): | Constraint | Value | | --- | --- | @@ -848,23 +848,23 @@ directly; the Collector capability policy does not inspect app manifests. ### Step 2 — typed configuration -[`StudyConfiguration.kt`](../core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt) +[`StudyConfiguration.kt`](../core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt) owns `AmbientLightConfiguration`: `sampling_period_us` is bounded to 200,000–10,000,000 and `change_threshold_millilux` to 0–100,000,000. Constructor validation is the one range authority; the catalog and Web editor must match it exactly. ### Step 3 — strict codec -[`StudyConfigurationCodec.kt`](../core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt) +[`StudyConfigurationCodec.kt`](../core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt) requires exactly both integer keys and encodes them through the exhaustive sealed-interface branch. Decode then re-encodes and byte-compares canonical JSON. The compact -[`P2ConfigurationTest`](../core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/P2ConfigurationTest.kt) +[`P2ConfigurationTest`](../core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/P2ConfigurationTest.kt) covers both boundaries, values just outside them, missing/unknown keys, wrong JSON types, and canonical round-trip. ### Step 4 — access kind -`AMBIENT_LIGHT_HARDWARE` is a closed [`AccessKind`](../core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/CollectorContracts.kt). +`AMBIENT_LIGHT_HARDWARE` is a closed [`AccessKind`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt). Every exhaustive `when` and the app build fail until the following participant-facing surfaces agree: @@ -894,8 +894,8 @@ Never hand-edit the generated Kotlin projection. CI proves that it and the catal ### Step 6 — the collector -Use the production [ambient-light collector](../collector/ambient-light/src/main/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollector.kt) -as the compact reference and the shared [sensor lifecycle owner](../collector/sensor-common/src/main/kotlin/cool/linc/particeps/collector/sensorcommon/AndroidSensorCollector.kt) +Use the production [ambient-light collector](../collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt) +as the compact reference and the shared [sensor lifecycle owner](../collector/sensor-common/src/main/kotlin/cool/jacoblin/particeps/collector/sensorcommon/AndroidSensorCollector.kt) for listener-thread ownership. Keeping the example as links instead of a copied implementation prevents this guide from becoming a second, stale collector. @@ -909,14 +909,14 @@ The boundaries worth preserving are: independently trigger one. - Units remain in field names and the catalog. There is no smoothing or derived indoor/presence inference. -- The focused [collector test](../collector/ambient-light/src/test/kotlin/cool/linc/particeps/collector/ambientlight/AmbientLightCollectorTest.kt) - proves coalescing and capture-time behavior; the shared [lifecycle test](../collector/sensor-common/src/test/kotlin/cool/linc/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt) +- The focused [collector test](../collector/ambient-light/src/test/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollectorTest.kt) + proves coalescing and capture-time behavior; the shared [lifecycle test](../collector/sensor-common/src/test/kotlin/cool/jacoblin/particeps/collector/sensorcommon/SensorSourceLifecycleTest.kt) proves failure cleanup. ### Step 7 — register in the app [`app/build.gradle.kts`](../app/build.gradle.kts) takes the module, and -[`CollectorApplication.kt`](../app/src/main/kotlin/cool/linc/particeps/CollectorApplication.kt) +[`CollectorApplication.kt`](../app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt) constructs `AmbientLightCollectorPlugin` in the compiled allowlist. `CollectorRegistry` rejects an unknown configured ID; the catalog is not runtime plugin loading. The Web control, codec, app allowlist, and catalog parity checks must all land together. @@ -926,9 +926,9 @@ allowlist, and catalog parity checks must all land together. - Configuration tests in `core/study-definition/src/test/...` covering nominal values, both range boundaries, values outside both bounds, unknown/missing keys, a wrong JSON type, and canonical round-trip. Follow - [`NetworkUsageConfigurationTest`](../core/study-definition/src/test/kotlin/cool/linc/particeps/core/definition/NetworkUsageConfigurationTest.kt). + [`NetworkUsageConfigurationTest`](../core/study-definition/src/test/kotlin/cool/jacoblin/particeps/core/definition/NetworkUsageConfigurationTest.kt). - Collector tests using the fake sink pattern in - [`SerializedCallbackCollectorTest`](../core/collector-api/src/test/kotlin/cool/linc/particeps/core/collector/SerializedCallbackCollectorTest.kt): + [`SerializedCallbackCollectorTest`](../core/collector-api/src/test/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollectorTest.kt): admission refusal, storage failure, failed registration, failed unregistration. - Add the collector to [`researcher-tools/examples/demo-study.json`](../researcher-tools/examples/demo-study.json) diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 7cc3235..6dfc0ba 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -41,13 +41,15 @@ Losing the Android signing private key means no future build can update a direct ### The rename is not an upgrade path -The rename deliberately left the release signing key alone; rotating it would strand every build already installed under the old certificate. That does not make the first post-rename APK an update to an installed pre-rename one. A device identifies an installed application by its `applicationId`, and that moved from `cool.linc.androiddatacollector` to `cool.linc.particeps`, so Android treats the two as unrelated applications: they install side by side, share nothing, and neither can update the other. The shared signing key means only that both builds came from the same maintainer. +Nothing published before `cool.jacoblin.particeps` can be updated in place, and two independent things guarantee that. The `applicationId` moved twice — `cool.linc.androiddatacollector`, then `cool.linc.particeps`, now `cool.jacoblin.particeps` — and a device identifies an installed application by that value, so Android treats each as unrelated to the others. The release signing key was then rotated as well, which changes the certificate a device compares on update. Either alone would be enough; both together mean there is no version of this app on anyone's phone that the current build can replace. + +The key was rotated to correct the certificate's subject, which named the pre-rename product and cannot be edited in place — a certificate is signed over its own subject, so changing it means issuing a new one. That was affordable only because every tag published to that point was a pre-1.0 release candidate and Developer Verification had not yet been registered. It stops being affordable the moment a real participant is running a released build, so it does not happen again. Every tester installs the new APK fresh and uninstalls the old one themselves. Uninstalling takes the old app's encrypted storage with it, and there is no migration: the storage key is non-exportable, so data written by the pre-rename build can leave only through that build's own export, in the pre-rename formats, which current tooling does not read. A tester holding data worth keeping should export it before uninstalling and analyse it with the pre-rename tooling. State this in the release notes for the first post-rename tag; a tester expecting an in-place update will otherwise read a correct install as a failed one. ## Android Developer Verification -Google's Developer Verification binds a verified developer identity to the package names that developer distributes and the certificates those packages are signed with. Registration is per package name. `cool.linc.particeps` has never been registered, so it needs its own entry, and an entry for `cool.linc.androiddatacollector` does not cover it. The certificate is the part that carries over unchanged: register the new `applicationId` with the same SHA-256 fingerprint the release keystore has always produced. +Google's Developer Verification binds a verified developer identity to the package names that developer distributes and the certificates those packages are signed with. Registration is per package name, and no name this project has used was ever registered, so `cool.jacoblin.particeps` needs its own entry from scratch. Register it against the fingerprint of the **current** keystore — the rotation above means any fingerprint recorded before it is wrong. ```bash apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk @@ -93,7 +95,7 @@ The rename is not a recurring step, but it is not finished when the code lands e 1. Rename the GitHub repository from `android-data-collector` to `particeps`, and update its description, topics, and homepage. Do this only once `main` carries the renamed tree, because the badges, documentation links, and `BASE_PATH` in it all assume the new name. Repository secrets survive a rename and need nothing. 2. Dispatch Pages fresh (`gh workflow run pages.yml --ref main`) rather than re-running the run that straddled the rename, verify the published HTML carries no old-name asset paths, and reissue any join link or QR that pointed at the old one. -3. Register `cool.linc.particeps` under Developer Verification with the existing certificate fingerprint, as above. +3. Register `cool.jacoblin.particeps` under Developer Verification with the existing certificate fingerprint, as above. 4. Cut the first post-rename tag, restore `version` and `date-released` in `CITATION.cff` as part of it, and say plainly in its release notes that it is a fresh install rather than an update. ## Pinned signers diff --git a/docs/p0-p2-implementation-contract.md b/docs/p0-p2-implementation-contract.md index 0b3bbf2..385b72f 100644 --- a/docs/p0-p2-implementation-contract.md +++ b/docs/p0-p2-implementation-contract.md @@ -48,8 +48,9 @@ exception; it does not permit changing an accepted app configuration. authenticated decryption. 7. Collector implementations remain compiled, closed-world modules. The catalog describes their contracts; it does not load code or turn unknown payloads into a generic runtime plugin. -8. The rename moved `applicationId` and the Kotlin root package from `cool.linc.androiddatacollector` - to `cool.linc.particeps`. Android treats the result as a different application: no upgrade, no +8. The `applicationId` and Kotlin root package have moved twice — `cool.linc.androiddatacollector`, + then `cool.linc.particeps`, now `cool.jacoblin.particeps` — and the release signing key was + rotated with the second move. Android treats the result as a different application: no upgrade, no migration, no data transfer, and no shared Keystore namespace. A pre-rename install is removed separately, and its studies, keys, encrypted segments, and staged upload bodies go with it. That is what made renaming the on-device names — Keystore aliases, WorkManager unique work names and diff --git a/docs/researcher-guide.md b/docs/researcher-guide.md index c6aa070..87102e2 100644 --- a/docs/researcher-guide.md +++ b/docs/researcher-guide.md @@ -211,7 +211,7 @@ The decoder rejects unknown keys, missing keys, and wrong JSON types outright. T lenient mode. `upload` is mandatory as a key: a study that does not upload writes `"upload": {}`. Constraints enforced by -[`core/study-definition`](../core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt): +[`core/study-definition`](../core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt): - `schema_version` is always `1`. There is no fallback reader and no migration path: a configuration either matches the current schema exactly or is refused. @@ -708,7 +708,7 @@ fingerprint is what turns that relationship into something checkable on the devi An organisation that wants one build to run only its own studies adds its key ID and public key to `TRUSTED_SIGNING_KEYS` in the `CollectorApplication` composition root -([`app/src/main/kotlin/cool/linc/particeps/CollectorApplication.kt`](../app/src/main/kotlin/cool/linc/particeps/CollectorApplication.kt)) +([`app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt`](../app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt)) and ships that build. The map is empty in the shipped build; populating it is strictly exclusive, so that build refuses every signer not listed, including studies from other teams. The pinned key also overrides whatever the configuration declares, so a configuration diff --git a/docs/system-design.md b/docs/system-design.md index 35bd125..b23fb9d 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -416,7 +416,7 @@ in memory. It drives the cipher directly rather than through `CipherInputStream` AEAD failure as a normal end of stream and would turn a tampered bundle into a silently truncated file. Plaintext therefore reaches only a mode-`0600` staging file before the tag is verified. `researcher-tools decrypt` then streams that file through -[`ResearchBundleVerifier`](../core/export/src/main/kotlin/cool/linc/particeps/core/export/ResearchBundleVerifier.kt) +[`ResearchBundleVerifier`](../core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt) and publishes it with an atomic move only after the authenticated document, signature, identities, ranges, transitions, and catalog payloads all pass. @@ -579,8 +579,8 @@ the same way. Protocol behavior is executable in the shared valid/hostile [conformance corpus](../protocol/v1/conformance-vectors.json). The neighbouring Kotlin -[configuration](../core/protocol/src/test/kotlin/cool/linc/particeps/core/protocol/ConfigurationProtocolTest.kt) -and [bundle](../core/export/src/test/kotlin/cool/linc/particeps/core/export/ResearchExportTest.kt) +[configuration](../core/protocol/src/test/kotlin/cool/jacoblin/particeps/core/protocol/ConfigurationProtocolTest.kt) +and [bundle](../core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt) tests cover JCS, raw keys, fixed framing, signature provenance, RFC 9180 context, exact ranges, and wrong-key/context/tamper rejection; TypeScript consumes the same corpus. Runtime tests cover the state machine, admission barrier, repeated export, watermark commit, and encrypted segmented @@ -595,8 +595,8 @@ instance IDs, assigned-ID persistence/export, upload-header exclusion, CLI bulk cross-language canonical bytes. Upload reliability has focused tests for the -[single-entry outbox](../app/src/test/kotlin/cool/linc/particeps/platform/FileUploadOutboxTest.kt) -and [HTTP adapter](../app/src/test/kotlin/cool/linc/particeps/platform/OkHttpStudyUploaderTest.kt): +[single-entry outbox](../app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt) +and [HTTP adapter](../app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt): recovery, exact byte replay, digest/length/range identity, redirect refusal, retry classification, `201`/exact-replay `200`, generic-`2xx` rejection, and exact seven-field receipt matching. Export tests separately verify streaming manual decryption publishes no successful output after AEAD diff --git a/researcher-tools/build.gradle.kts b/researcher-tools/build.gradle.kts index bc2d636..13f85d2 100644 --- a/researcher-tools/build.gradle.kts +++ b/researcher-tools/build.gradle.kts @@ -16,7 +16,7 @@ kotlin { } application { - mainClass = "cool.linc.particeps.researcher.MainKt" + mainClass = "cool.jacoblin.particeps.researcher.MainKt" } tasks.named("run") { diff --git a/researcher-tools/src/main/kotlin/cool/linc/particeps/researcher/Main.kt b/researcher-tools/src/main/kotlin/cool/jacoblin/particeps/researcher/Main.kt similarity index 95% rename from researcher-tools/src/main/kotlin/cool/linc/particeps/researcher/Main.kt rename to researcher-tools/src/main/kotlin/cool/jacoblin/particeps/researcher/Main.kt index 928274c..5ace094 100644 --- a/researcher-tools/src/main/kotlin/cool/linc/particeps/researcher/Main.kt +++ b/researcher-tools/src/main/kotlin/cool/jacoblin/particeps/researcher/Main.kt @@ -1,13 +1,13 @@ -package cool.linc.particeps.researcher +package cool.jacoblin.particeps.researcher -import cool.linc.particeps.core.crypto.HpkeCrypto -import cool.linc.particeps.core.definition.ProtocolBase64Url -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.export.ResearchExport -import cool.linc.particeps.core.export.ResearchBundleVerifier -import cool.linc.particeps.core.protocol.ConfigurationVerifier -import cool.linc.particeps.core.protocol.SignedConfigurationCodec -import cool.linc.particeps.core.protocol.SignedConfigurationEnvelope +import cool.jacoblin.particeps.core.crypto.HpkeCrypto +import cool.jacoblin.particeps.core.definition.ProtocolBase64Url +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.export.ResearchExport +import cool.jacoblin.particeps.core.export.ResearchBundleVerifier +import cool.jacoblin.particeps.core.protocol.ConfigurationVerifier +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.core.protocol.SignedConfigurationEnvelope import java.nio.channels.FileChannel import java.nio.file.Files import java.nio.file.Path diff --git a/researcher-tools/src/test/kotlin/cool/linc/particeps/researcher/DecryptCommandTest.kt b/researcher-tools/src/test/kotlin/cool/jacoblin/particeps/researcher/DecryptCommandTest.kt similarity index 98% rename from researcher-tools/src/test/kotlin/cool/linc/particeps/researcher/DecryptCommandTest.kt rename to researcher-tools/src/test/kotlin/cool/jacoblin/particeps/researcher/DecryptCommandTest.kt index 8eac99b..c392ed4 100644 --- a/researcher-tools/src/test/kotlin/cool/linc/particeps/researcher/DecryptCommandTest.kt +++ b/researcher-tools/src/test/kotlin/cool/jacoblin/particeps/researcher/DecryptCommandTest.kt @@ -1,4 +1,4 @@ -package cool.linc.particeps.researcher +package cool.jacoblin.particeps.researcher import com.google.gson.JsonObject import com.google.gson.JsonParser diff --git a/tools/catalog.py b/tools/catalog.py index 5056a7c..d7669c3 100644 --- a/tools/catalog.py +++ b/tools/catalog.py @@ -16,7 +16,7 @@ DEFAULT_CATALOG = ROOT / "protocol/v1/collector-catalog.json" DEFAULT_KOTLIN_CONTRACT = ( ROOT - / "core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/ProtocolEventContracts.kt" + / "core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventContracts.kt" ) ID = re.compile(r"[a-z][a-z0-9_.-]{2,63}\Z") FIELD = re.compile(r"[a-z][a-z0-9_]{1,63}\Z") @@ -84,7 +84,7 @@ def render_kotlin_contract(catalog: dict[str, Any]) -> str: ] lines = [ "// Generated by tools/catalog.py from protocol/v1/collector-catalog.json. Do not edit.", - "package cool.linc.particeps.core.collector", + "package cool.jacoblin.particeps.core.collector", "", "object ProtocolEventContracts {", " val contracts: Map = mapOf(", diff --git a/tools/catalog_parity.py b/tools/catalog_parity.py index 006978e..0218992 100644 --- a/tools/catalog_parity.py +++ b/tools/catalog_parity.py @@ -15,14 +15,14 @@ ROOT = Path(__file__).resolve().parents[1] -KOTLIN_MODEL = ROOT / "core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfiguration.kt" -KOTLIN_CODEC = ROOT / "core/study-definition/src/main/kotlin/cool/linc/particeps/core/definition/StudyConfigurationCodec.kt" +KOTLIN_MODEL = ROOT / "core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt" +KOTLIN_CODEC = ROOT / "core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt" WEB_TYPES = ROOT / "web/src/lib/particeps/types.ts" WEB_PARSE = ROOT / "web/src/routes/researcher/parse.ts" -RUNTIME = ROOT / "core/experiment-runtime/src/main/kotlin/cool/linc/particeps/core/runtime/ExperimentRuntime.kt" +RUNTIME = ROOT / "core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt" KOTLIN_EVENT_CONTRACT = ( ROOT - / "core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/ProtocolEventContracts.kt" + / "core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/ProtocolEventContracts.kt" ) @@ -333,7 +333,7 @@ def _check_interventions(catalog: dict[str, Any], root: Path) -> None: ) if not definition or definition.group(1) != "interventions.v1": raise ParityError("intervention runtime uses the wrong generated event contract") - contract = (root / "core/collector-api/src/main/kotlin/cool/linc/particeps/core/collector/CollectorContracts.kt").read_text(encoding="utf-8") + contract = (root / "core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt").read_text(encoding="utf-8") if "maximumEncodedEventBytes in 128..65_536" not in contract: raise ParityError("CollectorEventContract maximumEncodedEventBytes bounds drifted") diff --git a/tools/conformance/kotlin/ProtocolConformanceTest.kt b/tools/conformance/kotlin/ProtocolConformanceTest.kt index 6c5b717..b0e731a 100644 --- a/tools/conformance/kotlin/ProtocolConformanceTest.kt +++ b/tools/conformance/kotlin/ProtocolConformanceTest.kt @@ -2,15 +2,15 @@ package particeps.conformance import com.google.gson.JsonObject import com.google.gson.JsonParser -import cool.linc.particeps.core.crypto.HpkeCrypto -import cool.linc.particeps.core.definition.ProtocolCanonicalJson -import cool.linc.particeps.core.definition.StudyConfigurationCodec -import cool.linc.particeps.core.export.ResearchBundleVerifier -import cool.linc.particeps.core.export.ResearchExport -import cool.linc.particeps.core.export.UploadReceiptCodec -import cool.linc.particeps.core.protocol.ConfigurationVerifier -import cool.linc.particeps.core.protocol.JoinLink -import cool.linc.particeps.core.protocol.SignedConfigurationCodec +import cool.jacoblin.particeps.core.crypto.HpkeCrypto +import cool.jacoblin.particeps.core.definition.ProtocolCanonicalJson +import cool.jacoblin.particeps.core.definition.StudyConfigurationCodec +import cool.jacoblin.particeps.core.export.ResearchBundleVerifier +import cool.jacoblin.particeps.core.export.ResearchExport +import cool.jacoblin.particeps.core.export.UploadReceiptCodec +import cool.jacoblin.particeps.core.protocol.ConfigurationVerifier +import cool.jacoblin.particeps.core.protocol.JoinLink +import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec import java.io.ByteArrayOutputStream import java.io.File import java.nio.ByteBuffer diff --git a/tools/retired_identity_audit.py b/tools/retired_identity_audit.py index 4c59fb8..608c30b 100644 --- a/tools/retired_identity_audit.py +++ b/tools/retired_identity_audit.py @@ -11,7 +11,7 @@ retirement. Adding a file to that list is a deliberate act with a written justification, which is the review the rename issue asked for. -The second is the fresh-install boundary. Moving `applicationId` to `cool.linc.particeps` is what +The second is the fresh-install boundary. Moving `applicationId` to `cool.jacoblin.particeps` is what makes a pre-rename install a different application that Android will not upgrade, and it is also what made renaming the Keystore aliases, work names, and storage suffixes safe. Both halves are asserted here: the identity is pinned, and no source file may read the retired namespace. @@ -45,7 +45,7 @@ # path -> why the retired spelling belongs there. Nothing else may carry one. ALLOWED: dict[str, str] = { "README.md": "documents that pre-rename artifacts and installs are unsupported", - "app/src/androidTest/kotlin/cool/linc/particeps/AndroidConfigurationImportTest.kt": + "app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt": "retired-identity rejection fixture: import must fail closed on the old magic", "docs/maintainers/release.md": "records the cutover a maintainer still has to finish", "docs/p0-p2-implementation-contract.md": "invariant naming exactly which inputs are rejected", @@ -63,8 +63,11 @@ "web/tests/join.spec.ts": "retired-identity rejection fixture", } -APPLICATION_ID = "cool.linc.particeps" -RETIRED_NAMESPACE = "cool.linc.androiddatacollector" +APPLICATION_ID = "cool.jacoblin.particeps" +# Every namespace this application has ever shipped under. Each one is a different application to +# Android, so none of them may be read: there is no install to migrate from, only data that the +# uninstall of that build already destroyed. +RETIRED_NAMESPACES = ("cool.linc.androiddatacollector", "cool.linc.particeps") SKIP_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".jar", ".zip", ".keystore", ".jks"} @@ -123,11 +126,12 @@ def audit_fresh_install_boundary(root: Path) -> list[str]: text = (root / name).read_text(encoding="utf-8") except (UnicodeDecodeError, OSError): continue - if RETIRED_NAMESPACE in text: - problems.append( - f"{name}: references {RETIRED_NAMESPACE}. Nothing may read the retired namespace; " - "there is no migration path from it." - ) + for namespace in RETIRED_NAMESPACES: + if namespace in text: + problems.append( + f"{name}: references {namespace}. Nothing may read a retired namespace; " + "there is no migration path from any of them." + ) return problems diff --git a/tools/tests/test_retired_identity_audit.py b/tools/tests/test_retired_identity_audit.py index 5873d85..4865199 100644 --- a/tools/tests/test_retired_identity_audit.py +++ b/tools/tests/test_retired_identity_audit.py @@ -98,10 +98,13 @@ def test_reading_the_retired_namespace_fails(self) -> None: { "app/build.gradle.kts": BUILD_FILE, "src/Restore.kt": 'val legacy = "cool.linc.androiddatacollector"', + "src/AlsoRestore.kt": 'val previous = "cool.linc.particeps"', }, ) problems = audit_fresh_install_boundary(root) - self.assertTrue(any("retired namespace" in problem for problem in problems)) + # Both retired namespaces, not just the first: an application that has been renamed + # twice has two dead namespaces, and reading either is the same mistake. + self.assertEqual(2, sum("retired namespace" in problem for problem in problems)) def test_every_allow_list_entry_still_exists_and_still_needs_the_exception(self) -> None: from tools.retired_identity_audit import ALLOWED From 2b4173418b6e86c9fec150f711c6f963586ae355 Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 03:59:43 +0800 Subject: [PATCH 4/8] Stop the daily reminder naming the study or outliving its own truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge review found two defects in the reminder, both mine. The notification put the researcher-supplied study title in its content title, while the same file's comment claimed it disclosed no study content. A study called "Smartphone use and depressive symptoms in adolescents" would have rendered as the bold first line of a lock-screen notification, every day, for the study's whole duration — including while paused, a state in which the phone previously showed nothing at all. The title is now the app's own name, which the launcher and Android's settings already display. The ongoing collection notification still carries the study title, but only while collection is really running and only as its secondary line. Pausing also left the already-posted notification standing, so the lock screen could keep asserting "Still collecting" for up to a day after collection stopped — the exact opposite of what the reminder is for. Starting or stopping collection now retracts it; the next daily run posts the truth. Re-posting immediately would turn a daily reminder into a notification on every pause. The audit gained a repository-wide pattern for cool.linc.particeps. Unlike the first retired namespace it contains no ADC spelling, so it had been enforced only on the six suffixes the boundary check reads, and a regression in a Python tool, a workflow, or TypeScript would have passed. Verified by injection into all three. The participant guide now describes the reminder. It enumerates every notification a participant sees, and this one was missing from that list and from the notification-permission section. --- .../jacoblin/particeps/DailyStatusWorker.kt | 15 ++++++++---- .../platform/AndroidStudyPlatform.kt | 23 +++++++++++++++++-- docs/participant-guide.md | 6 +++-- tools/retired_identity_audit.py | 3 +++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt index 0383e00..2608d6a 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt @@ -22,8 +22,12 @@ import kotlinx.coroutines.flow.first * — a study that collects for weeks should keep saying so rather than becoming invisible, because * consent that nobody is reminded of is consent in name only. * - * It reports state and nothing else. No counts, no collector names, no study content: a - * notification is readable on a lock screen by whoever is holding the phone. + * It reports state and nothing else. No counts, no collector names, and deliberately not the study + * title either: this arrives every day for the study's whole duration, including while paused, and + * a lock screen is readable by whoever is holding the phone. The title line is the app's own name, + * which the launcher and Android's own settings already show. The ongoing collection notification + * does carry the study title, but only while collection is actually running and only as its + * secondary line; a daily reminder that repeated it would be a standing disclosure instead. */ class DailyStatusWorker( context: Context, @@ -33,9 +37,10 @@ class DailyStatusWorker( val application = applicationContext as CollectorApplication val snapshot = application.session.snapshot.first { it.initialized } val metadata = snapshot.runtime.metadata - val title = snapshot.configuration?.title val state = metadata?.state - if (title == null || (state != ExperimentState.RUNNING && state != ExperimentState.PAUSED)) { + if (snapshot.configuration == null || + (state != ExperimentState.RUNNING && state != ExperimentState.PAUSED) + ) { // Finished, withdrawn, deleted, or never started. Nothing to remind anyone about, and // the periodic request outlives the study unless it retires itself here. return Result.success() @@ -87,7 +92,7 @@ class DailyStatusWorker( 0, android.app.Notification.Builder(applicationContext, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(title) + .setContentTitle(applicationContext.getString(R.string.app_name)) .setContentText(text) .setStyle(android.app.Notification.BigTextStyle().bigText(text)) .setContentIntent( diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt index 2d6bed6..a5886f7 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt @@ -45,10 +45,27 @@ class AndroidStudyCollectionHost( ) : StudyCollectionHost { override fun start(studyTitle: String, usesLocation: Boolean) { CollectionService.start(context, studyTitle, usesLocation) + retractStaleDailyReminder() } override fun stop() { CollectionService.stop(context) + retractStaleDailyReminder() + } + + /** + * Drops a standing daily reminder whenever collection starts or stops. + * + * The reminder is posted once a day and states which state the study is in, so the moment that + * changes the notification sitting on the lock screen is a false statement — and the worst + * direction to be wrong in is a paused study still asserting "Still collecting", which is the + * exact opposite of what the reminder exists to say. Retracting is enough: the next daily run + * posts the truth, whereas re-posting here would turn a daily reminder into a notification on + * every pause and resume. + */ + private fun retractStaleDailyReminder() { + context.getSystemService(NotificationManager::class.java) + ?.cancel(DailyStatusWorker.NOTIFICATION_TAG, 0) } } @@ -92,8 +109,10 @@ class AndroidStudyWorkScheduler( * re-entering a study — a resume, a process restart — does not push the next reminder a full * day away each time. * - * Deliberately not cancelled on pause. A paused study is exactly the case the reminder exists - * for; [cancelCollectionWork] retires it when the study actually ends. + * The schedule is deliberately not cancelled on pause — a paused study is exactly the case the + * reminder exists for — and [cancelCollectionWork] retires it when the study actually ends. The + * already-posted notification is a separate matter: pausing retracts it, because it states a + * state that has just stopped being true. See [AndroidStudyCollectionHost]. */ private fun scheduleDailyStatus() { workManager.enqueueUniquePeriodicWork( diff --git a/docs/participant-guide.md b/docs/participant-guide.md index 254a996..b000347 100644 --- a/docs/participant-guide.md +++ b/docs/participant-guide.md @@ -213,7 +213,9 @@ Tap anywhere on a row that is not granted yet and the app sends you straight to ### Notifications (通知) -Used for scheduled study activities and for the notification that stays visible while collection is running. Activities use Android's background work system, which is not an exact alarm: battery saving, Doze, or system scheduling can delay them. +Used for three things: scheduled study activities, the notification that stays visible while collection is running, and a once-a-day reminder of where the study stands. Activities use Android's background work system, which is not an exact alarm: battery saving, Doze, or system scheduling can delay them. + +The daily reminder says either that the study is still collecting, or that it is paused and since when. It exists for the second case: a pause changes nothing else on the phone, so a study you meant to resume can sit stopped for weeks without anything saying so. It is a quiet notification — no sound — and it names only the app and the state, never the study, so it discloses nothing to someone glancing at your lock screen. It stops when the study finishes or you withdraw. ### Sensor hardware (感測器硬體) and basic network state @@ -363,7 +365,7 @@ Finishing or withdrawing does not strand data the research team was already owed ### Pause and resume -When you press Pause (暫停), the app writes a pause boundary, stops the sources, and flushes events that were already queued before that boundary. Once the status line shows Paused (已暫停), no new study events are accepted for the period you are paused. Data already collected stays on your phone, encrypted. The ongoing notification and any visible scheduled-activity notification go away. A survey cannot be opened or submitted while paused. +When you press Pause (暫停), the app writes a pause boundary, stops the sources, and flushes events that were already queued before that boundary. Once the status line shows Paused (已暫停), no new study events are accepted for the period you are paused. The line under it tells you when the pause started and how long it has lasted. Data already collected stays on your phone, encrypted. The ongoing notification and any visible scheduled-activity notification go away, and the daily reminder starts saying you are paused instead. A survey cannot be opened or submitted while paused. Pressing Resume (繼續收集) starts a new collection interval. Data volume and app and screen use are not backfilled: the app does not go back and collect what happened while you were paused. Calendar time and scheduled-activity availability still pass during a pause, so the app expires missed activities and reconciles any remaining ones when you resume. diff --git a/tools/retired_identity_audit.py b/tools/retired_identity_audit.py index 608c30b..3c82442 100644 --- a/tools/retired_identity_audit.py +++ b/tools/retired_identity_audit.py @@ -40,6 +40,9 @@ "routing_header": r"[Xx]-[Aa][Dd][Cc]-", "python_package": r"adc[-_]analysis", "bare_token": r"(? why the retired spelling belongs there. Nothing else may carry one. From e6b442e4ee7324273d0dd79a8141a9e2d48e0efc Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 04:44:50 +0800 Subject: [PATCH 5/8] Retire the P0-P2 implementation contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a plan, written in the future tense about work that shipped three releases ago, and it had become a de facto changelog nobody updates. Every section restated a document that owns the subject: the invariants compress protocol/v1/README.md and system-design.md, the responsibility map repeats system-design's module table, the upload transaction was a third copy of the receipt contract, and the P2 design limits are covered in more detail by the researcher guide. One section was not a duplicate. The five phase-review criteria are a standard for finishing work, so they move to CONTRIBUTING.md, where the person about to open a pull request will see them. CONTRIBUTING.md also claimed the collector capability boundary is enforced by review rather than by a check. It is enforced by tools/collector_assurance.py on every pull request. The claim is replaced by what the check actually covers, and by the gap it does not — the manifest, which issue #11 tracks. --- .github/workflows/receiver.yml | 2 - CONTRIBUTING.md | 12 +- README.md | 3 +- docs/component-boundaries.md | 1 - docs/p0-p2-implementation-contract.md | 159 -------------------------- docs/researcher-guide.md | 2 +- docs/system-design.md | 2 +- docs/threat-model.md | 2 +- receiver/README.md | 2 +- 9 files changed, 16 insertions(+), 169 deletions(-) delete mode 100644 docs/p0-p2-implementation-contract.md diff --git a/.github/workflows/receiver.yml b/.github/workflows/receiver.yml index 2c60fe2..4b3d8ea 100644 --- a/.github/workflows/receiver.yml +++ b/.github/workflows/receiver.yml @@ -7,13 +7,11 @@ on: paths: - "receiver/**" - "protocol/v1/**" - - "docs/p0-p2-implementation-contract.md" - ".github/workflows/receiver.yml" pull_request: paths: - "receiver/**" - "protocol/v1/**" - - "docs/p0-p2-implementation-contract.md" - ".github/workflows/receiver.yml" workflow_dispatch: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff43467..f047e96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Collectors observe a source and emit events. They do not write files, change stu 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. -Note that the boundary is enforced by the dependency graph and by review, not by a sandbox. There is currently no architecture test asserting it. +The boundary is checked, not merely reviewed. `tools/collector_assurance.py` reads `assurance/collector-policy.json` and fails CI on a forbidden import, a forbidden Gradle dependency, or a forbidden symbol in a compiled class. What it does not read is the manifest, so a collector module can still declare a permission or a component that nothing stops — that gap is tracked in issue #11. ### What review will look at @@ -73,6 +73,16 @@ Two conventions worth knowing: Keep them focused — one collector, or one fix. Explain what changed and why, and say explicitly if the change affects what data can be collected or what a participant sees. +A change is finished when all five of these hold: + +1. the targeted tests and the full build checks pass; +2. a review has checked the failure paths, not only the success path; +3. nothing was added that a simpler version would not need — no duplicate implementation, no legacy path, no unused code, no avoidable dependency; +4. the code you touched is clearer than you found it; and +5. a reader who has never seen the change can find its specification, its source, its tests, and its operational documentation starting from the root documentation. + +The fifth is the one people skip. It is also the one that decides whether anyone can maintain this after you. + By contributing you agree that your contribution is licensed under the [MIT License](LICENSE). ## Security issues diff --git a/README.md b/README.md index fb10df9..fe7535b 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ flowchart LR Platform-independent modules contain no `android.*` imports, which keeps the domain logic testable on the JVM. [Component boundaries](docs/component-boundaries.md) documents the contracts. -New contributors should treat [`protocol/v1`](protocol/v1/README.md) as the normative wire contract, the [collector catalog](protocol/v1/collector-catalog.json) as the schema source, and [`docs/p0-p2-implementation-contract.md`](docs/p0-p2-implementation-contract.md) as the implementation decision record. Trace one path through the [configuration codec](core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt), [signed envelope](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt), [bundle exporter](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt), [bundle verifier](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt), [single-entry outbox](app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), [HTTP adapter](app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt), [receiver handler](receiver/src/index.ts), and the offline [`particeps-analysis`](particeps-analysis/README.md) pipeline. The join path is similarly short: [Web authoring](web/src/lib/particeps/join.ts), [shared parser](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt), [Android staging](app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt), [intent entry](app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt), then the existing [session import](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt). The [outbox](app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt), [uploader](app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt), and [receiver](receiver/tests/receiver.test.ts) tests make crash/replay and receipt semantics executable. Receiver deployment and R2 operations start at [`receiver/README.md`](receiver/README.md), and the Collector capability policy lives under [`assurance`](assurance/README.md). +New contributors should treat [`protocol/v1`](protocol/v1/README.md) as the normative wire contract, the [collector catalog](protocol/v1/collector-catalog.json) as the schema source, and [`docs/system-design.md`](docs/system-design.md) for how the modules fit together. Trace one path through the [configuration codec](core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt), [signed envelope](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt), [bundle exporter](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt), [bundle verifier](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt), [single-entry outbox](app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), [HTTP adapter](app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt), [receiver handler](receiver/src/index.ts), and the offline [`particeps-analysis`](particeps-analysis/README.md) pipeline. The join path is similarly short: [Web authoring](web/src/lib/particeps/join.ts), [shared parser](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt), [Android staging](app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt), [intent entry](app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt), then the existing [session import](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt). The [outbox](app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt), [uploader](app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt), and [receiver](receiver/tests/receiver.test.ts) tests make crash/replay and receipt semantics executable. Receiver deployment and R2 operations start at [`receiver/README.md`](receiver/README.md), and the Collector capability policy lives under [`assurance`](assurance/README.md). For `random_window`, trace the signed model and bounds in [`StudyConfiguration.kt`](core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfiguration.kt), @@ -204,7 +204,6 @@ runtime, session, and app policy tests make each boundary executable. | [Component boundaries](docs/component-boundaries.md) | Module contracts and invariants | | [Threat model](docs/threat-model.md) | Trust assumptions and limitations, for ethics review | | [Normative Protocol v1](protocol/v1/README.md) | JCS, keys, join URI, binary framing, bundle document, upload, receipt, and conformance corpora | -| [P0–P2 implementation contract](docs/p0-p2-implementation-contract.md) | Locked architectural decisions and scope | | [Collector capability policy](assurance/README.md) | Static source, bytecode, and dependency boundaries for collectors | | [Ciphertext receiver](receiver/README.md) | R2-only Worker contract, verification commands, deployment, and operations | | [Offline analysis](particeps-analysis/README.md) | Ciphertext inventory, verification, reassembly, and typed Parquet materialization | diff --git a/docs/component-boundaries.md b/docs/component-boundaries.md index 4d065c9..7881601 100644 --- a/docs/component-boundaries.md +++ b/docs/component-boundaries.md @@ -7,7 +7,6 @@ enforcement mechanism. New contributors should read this with the [normative Protocol v1 contract](../protocol/v1/README.md), the [collector catalog](../protocol/v1/collector-catalog.json), the -[P0–P2 implementation contract](p0-p2-implementation-contract.md), and [`assurance`](../assurance/README.md). The concrete upload seam is deliberately short: [StudyUploader](../core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt), [FileUploadOutbox](../app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), diff --git a/docs/p0-p2-implementation-contract.md b/docs/p0-p2-implementation-contract.md deleted file mode 100644 index 385b72f..0000000 --- a/docs/p0-p2-implementation-contract.md +++ /dev/null @@ -1,159 +0,0 @@ -# P0–P2 implementation contract - -This document is the implementation contract for roadmap issues #7–#19. It records the -cross-module decisions that must stay stable while the work is delivered in phases. The issue -bodies remain the product requirements; this document says where each responsibility belongs and -which shortcuts are forbidden. - -## Scope - -This delivery includes: - -- P0: Protocol v1 finalization, a replay-safe Android upload outbox, the collector/event catalog, - and the Collector capability policy; -- P1: the R2-only ciphertext receiver, offline Python verification/reassembly, typed Parquet, and - immutable join links; -- P2: battery state, temporal context, gyroscope, ambient light, proximity, and randomized local - EMA windows. - -It excludes iOS/P3, remote configuration, remote triggers, participant/device attestation, -receiver administration, receiver-side decryption, databases other than R2 ciphertext storage, -and analysis sinks other than Parquet. Existing issue #5 is the sole mutable-hosted-content -exception; it does not permit changing an accepted app configuration. - -## Non-negotiable invariants - -1. `schema_version` remains the JSON number `1`. Nothing else does: the Protocol v1 identity - strings are `PTCCFG01`, `PTCEXP01`, `particeps-research-bundle-v1`, `particeps://join/v1`, - `application/vnd.particeps.research-bundle`, and `X-Particeps-*`. This is a destructive pre-1.0 - replacement of the retired Android Data Collector identity, layered on the earlier destructive - replacement of the pre-v1 encodings. No reader, migration, dual parser, compatibility flag, - alias, forwarding module, Tink wire keyset, or fallback is retained for either. `ADCCFG01`, - `ADCEXP01`, `research-bundle-v1`, `adc://join/v1`, `application/vnd.adc.research-bundle`, and - `X-ADC-*` are rejected inputs with hostile-corpus coverage. -2. Protocol input is closed-world and fail-closed. Unknown members, malformed UTF-8, duplicate - JSON members, noncanonical JSON, invalid key encodings, unsupported collectors, wrong platform, - wrong cryptographic context, malformed framing, and trailing bytes are rejected. -3. Android and future iOS configurations may share an experiment ID but never a configuration ID - or signature. This build accepts only an Android-targeted configuration. -4. Upload bytes are fully encrypted and durably staged before HTTP begins. One bundle ID always - denotes one immutable byte string. The watermark advances only after an exact matching durable - receiver receipt. -5. Receiver routing metadata is untrusted. Receiver success proves only that bounded ciphertext - bytes were stored in R2; it does not prove participant, device, configuration, or plaintext - authenticity. -6. Offline analysis verifies the entire bundle before publishing plaintext-derived records. An - invalid bundle produces no partial rows. Event identity is - `(experiment_id, configuration_id, participant_instance_id, sequence_number)` only after - authenticated decryption. -7. Collector implementations remain compiled, closed-world modules. The catalog describes their - contracts; it does not load code or turn unknown payloads into a generic runtime plugin. -8. The `applicationId` and Kotlin root package have moved twice — `cool.linc.androiddatacollector`, - then `cool.linc.particeps`, now `cool.jacoblin.particeps` — and the release signing key was - rotated with the second move. Android treats the result as a different application: no upgrade, no - migration, no data transfer, and no shared Keystore namespace. A pre-rename install is removed - separately, and its studies, keys, encrypted segments, and staged upload bodies go with it. That - is what made renaming the on-device names — Keystore aliases, WorkManager unique work names and - tags, no-backup staging file names, segment and metadata file suffixes — a safe mechanical change - in that commit and only in that commit. The same renames applied without the `applicationId` - change would orphan enqueued work and make already-written ciphertext permanently unreadable. - -## Responsibility map - -| Concern | Authoritative location | Implementations/consumers | -| --- | --- | --- | -| Normative Protocol v1, catalog, vectors | `protocol/v1/` | Kotlin, TypeScript, Python | -| Typed study configuration | `:core:study-definition` | Android, researcher tooling | -| Signed configuration envelope/trust | `:core:protocol` | Android, researcher tooling | -| Raw-key Ed25519 verification and RFC 9180 HPKE primitives | `:core:crypto` | `:core:protocol`, `:core:export`, researcher tooling | -| Encrypted bundle framing/document | `:core:export` | Android export/outbox, Python analysis | -| Upload planning/watermark | `:core:study-application` | Android session/runtime | -| Durable body staging and HTTP | `:app` platform adapters | WorkManager upload worker | -| Ciphertext ingress | `receiver/` | Cloudflare Worker and R2 only | -| Offline validation and datasets | `particeps-analysis/` | Local/R2 source, Parquet sink | -| Collector implementations | `:collector:*` | Android composition root | -| Collector capability policy | `assurance/collector-policy.json` | CI | - -No generic service/repository/controller hierarchy is introduced across these concerns. - -## Protocol v1 decisions - -- Signed configuration JSON uses RFC 8785 JCS. The schema permits only bounded integral JSON - numbers; semantic 64-bit counters, times, monotonic values, and byte counts use canonical decimal - strings. -- Ed25519 and X25519 keys are raw 32-byte values encoded as unpadded base64url. Private CLI key - files use the same raw encoding. -- Floating-point configuration is removed. Location displacement becomes integer millimeters. -- The configuration carries an explicit platform target and decimal minimum client build number. -- `PTCCFG01` has fixed Ed25519 signature length and no legacy signature-length field. -- `PTCEXP01` carries a UUID bundle ID, complete configuration SHA-256, fixed-suite RFC 9180 - X25519/HKDF-SHA-256/AES-256-GCM wrapped content key, and an AES-256-GCM encrypted document. -- HPKE context and content AAD bind the bundle format, bundle ID, configuration digest, and - researcher key ID. The authenticated document repeats and verifies those identities. -- The document includes canonical configuration bytes, configuration signature provenance, - producer platform/client version, bundle kind, retained/uploaded/durable boundaries, actual - range/count, and decimal-string research clocks/sequences. - -## Android upload transaction - -The transaction order is: - -1. Recover an existing valid staged bundle or create exactly one in no-backup storage. -2. Send that fixed-length file with its exact digest and metadata; redirects and implicit request - replay are disabled. -3. Accept only a matching `201 Created` or exact-replay `200 OK` receipt. -4. Persist the watermark through the staged last sequence. -5. Remove the outbox manifest, then its now-harmless body orphan. -6. Reclaim eligible encrypted event segments under the existing quota policy. - -A crash before step 4 resends identical bytes. A crash after step 4 clears the already-covered -stage before creating another. I/O, 408, 425, 429, and 5xx are retryable; all other HTTP/protocol -failures are terminal for delivery but never stop collection. - -## Receiver and analysis boundaries - -The Worker exposes only the Protocol v1 upload POST and performs bounded streaming into an -immutable R2 object. It has no private key, decrypt path, list/download/delete/admin route, D1, -Queue, KV, Durable Object, dashboard, or runtime configuration. - -`particeps-analysis` uses one directional pipeline: - -```text -BundleSource -> immutable ciphertext inventory -> full validation -> deterministic reassembly - -> typed Parquet sink -``` - -Local and R2 sources first copy ciphertext into a content-addressed cache. Plaintext is staged with -mode 0600, and validated intermediate data or a complete dataset is published by atomic rename. -Conflicting duplicates remain explicit conflicts; there is no last-write-wins or unknown-schema -fallback. - -## P2 design limits - -- Each new collector is a separate small Gradle module using the existing collector API and event - sink. Gyroscope, ambient light, and proximity share only the narrow - `collector:sensor-common` listener-thread lifecycle helper; configuration, payload mapping, - rate/change policy, and disclosure stay in their own modules. There is no runtime plugin or - generic sensor-schema framework. -- Battery and temporal context use runtime-registered, non-exported receivers. Gyroscope, ambient - light, and proximity use hardware preflight without new Android permissions or components. -- Every new numeric sensor value must be finite; catalog maximum event size and field schema are - enforced by tests and offline validation. -- Randomized EMA reuses durable `InterventionOccurrence` records. A CSPRNG-selected instant is - committed before WorkManager is enqueued; committed random occurrences are never rescheduled. - Only future, unmaterialized local-date windows follow a later time-zone change. Daily and total - caps consume eligible slots in local-date planning order, then signed window array order, then - ordinal; the CSPRNG selects the minute within the chosen slot, not which window survives - truncation. - -## Phase review criteria - -Each phase is complete only after: - -1. targeted and full relevant test/build checks pass; -2. a correctness/security review checks failure paths and stated invariants; -3. a simplicity review removes unnecessary abstractions, duplicate implementations, legacy paths, - unused code, and avoidable dependencies; -4. scout-rule cleanup leaves touched code clearer than before; and -5. an independent reviewer unfamiliar with the implementation can locate the specification, - source, tests, and operational documentation from the root documentation. diff --git a/docs/researcher-guide.md b/docs/researcher-guide.md index 87102e2..88d791f 100644 --- a/docs/researcher-guide.md +++ b/docs/researcher-guide.md @@ -32,7 +32,7 @@ support, and how the two key pairs must be handled. For implementation work, start with the [normative Protocol v1 contract](../protocol/v1/README.md), its [collector catalog](../protocol/v1/collector-catalog.json), the -[P0–P2 decision record](p0-p2-implementation-contract.md), and the +[system design](system-design.md), and the [Collector capability policy](../assurance/README.md). Configuration, envelope, export, outbox, and HTTP behavior live beside their tests in `core/study-definition`, `core/protocol`, `core/export`, and `app/src/{main,test}/…/platform`; those links are indexed in the repository README. diff --git a/docs/system-design.md b/docs/system-design.md index b23fb9d..1fb8dd9 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -8,7 +8,7 @@ a researcher endpoint is an option a study configuration turns on. The [normative Protocol v1 contract](../protocol/v1/README.md) and its [collector catalog](../protocol/v1/collector-catalog.json) define the wire and event schemas. The -[P0–P2 implementation contract](p0-p2-implementation-contract.md) records the locked decisions, and +the [protocol specification](../protocol/v1/README.md) is normative for the wire format, and [`assurance`](../assurance/README.md) defines the static Collector capability policy. This document explains how the current modules realize those contracts. diff --git a/docs/threat-model.md b/docs/threat-model.md index 20f06b3..e929df8 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -4,7 +4,7 @@ A reference description of the protections in the current release, the limitatio The [normative Protocol v1 contract](../protocol/v1/README.md), its [collector catalog](../protocol/v1/collector-catalog.json), the -[implementation decision record](p0-p2-implementation-contract.md), and +[system design](system-design.md), and [Collector capability policy](../assurance/README.md) are the implementation sources behind the claims in this document. diff --git a/receiver/README.md b/receiver/README.md index 4f6206b..022c5d7 100644 --- a/receiver/README.md +++ b/receiver/README.md @@ -4,7 +4,7 @@ This directory contains the complete server-side surface for automatic uploads. Cloudflare Worker, one deployment-fixed `POST` path, and one R2 binding. The normative request, bundle, and receipt contract is [`../protocol/v1/README.md`](../protocol/v1/README.md); the phase boundaries are in -[`../docs/p0-p2-implementation-contract.md`](../docs/p0-p2-implementation-contract.md). +[`../protocol/v1/README.md`](../protocol/v1/README.md). The Worker accepts a bounded `PTCEXP01` ciphertext stream and stores it under its bundle UUID. It checks the Protocol v1 content headers, untrusted routing claims, visible outer bundle identities, From aa35220bfe4cdec09636b045a5a28f088353d32c Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 05:03:25 +0800 Subject: [PATCH 6/8] Correct the documentation against the code, and describe what was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit compared every document to the tree it describes. This fixes what it found wrong, and adds three things that were true but written down nowhere. It changes nothing for style; duplication and sentence length are a later pass. The most important gap was in the participant guide. It documented one way to enrol — the system file picker — while the application also ships a particeps://join/v1 handler and the researcher guide tells researchers to recruit with a link or a QR code. The participant scanning that code held the one document that never mentioned it, and none of the four codes that path can produce appeared in its troubleshooting section. One of those four is not a fault: a join is refused while another study or a pending deletion exists. That path also carries a limitation nobody had written down. The join URI carries signer_fingerprint as a query parameter, so a participant who arrives by link is shown a fingerprint that came from that same link. Comparing the two shows only that the link agrees with itself. The guide now says so where it explains the check. The daily status reminder existed in code and in one sentence of the participant guide. It is now in the threat model as a disclosure surface a bystander can read without decrypting anything, in the system design's background-execution section, and in the researcher guide where a researcher plans participant contact — with its limits: best-effort timing, no configuration field to switch it off, and no guarantee anyone was reminded. The collector implementation guide was wrong in six places that would each have misled someone writing a collector: EmitResult has four members and the guide documented three, AccessKind has ten and the guide listed seven, two lifecycle signatures were quoted with the wrong return types, the module was said to hold two files and holds five, a guard was named that does not exist, and a label hook was named that does not exist. Verified against the source, member by member. Smaller corrections: the release guide presented a finished cutover as pending work; the system design over-claimed what the participant dashboard shows per collector; the data dictionary promised every exported field and omitted the transition history; README listed six of seven CLI commands and one of two distribution paths; the protocol specification said Python does not read the join corpus three lines after telling the reader to run a script that does. --- README.md | 3 +- docs/component-boundaries.md | 5 +- docs/data-collector-implementation-guide.md | 59 +++++++++++++++++---- docs/data-dictionary.md | 44 +++++++++++++++ docs/maintainers/release.md | 23 ++++---- docs/participant-guide.md | 24 +++++++-- docs/researcher-guide.md | 27 ++++++++++ docs/system-design.md | 33 +++++++++++- docs/threat-model.md | 6 ++- protocol/v1/README.md | 6 ++- 10 files changed, 200 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index fe7535b..5957832 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The name describes where the design puts the participant, and it is worth being 1. **Generate your keys.** One Ed25519 pair to sign study configurations, one X25519 HPKE pair to decrypt bundles. `researcher-tools` writes raw 32-byte keys as unpadded base64url. 2. **Write the study.** A strict Protocol v1 RFC 8785 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 `.partcfg` 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 `.partcfg`. 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. +4. **Distribute.** Participants install the app and import your `.partcfg`, or open a `particeps://join/v1` link that names where those exact bytes are served and pins their SHA-256. 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. 6. **Export and analyse.** The participant exports an encrypted bundle and sends it to you. If the study declares an upload endpoint, the app also delivers immutable ciphertext bundles to an R2 receiver on a schedule. `particeps-analysis` inventories, verifies, decrypts, reassembles, and writes typed Parquet offline. @@ -132,6 +132,7 @@ signing-keygen generate an Ed25519 signing pair hpke-keygen generate a raw X25519 HPKE key pair canonicalize strictly parse and emit a canonical configuration sign sign a canonical configuration into .partcfg +personalize sign one canonical configuration and .partcfg per row of an assigned-code mapping check-config verify envelope, signature, platform, validity window, and client build; optionally pin the signer decrypt decrypt a .partexp into particeps-research-bundle-v1 JSON ``` diff --git a/docs/component-boundaries.md b/docs/component-boundaries.md index 7881601..70d9f3c 100644 --- a/docs/component-boundaries.md +++ b/docs/component-boundaries.md @@ -61,8 +61,9 @@ researcher tools -> study definition + signed protocol + export format - The upload watermark advances only on `201 Created` or exact-replay `200 OK` with a canonical seven-field receipt matching the outbox manifest, and never backwards. Redirects, `202`, other statuses, and malformed or mismatched receipts cannot commit. A confirmed delivery is the only thing that makes - local data reclaimable. Reclaiming starts only above 80% of the study's quota and takes whole - leading segments at or below the watermark. Undelivered data is never released to make room. + local data reclaimable. Reclaiming starts only above 80% of the study's quota, stops at 60% of + it, and takes whole leading segments at or below the watermark. Undelivered data is never + released to make room. - Study metadata is self-sufficient: opening a study validates framing and sequence contiguity from the plaintext frame headers and reads `lastEvents` from the metadata, so load cost is linear in frames rather than in bytes decrypted. Event payloads are authenticated when read; diff --git a/docs/data-collector-implementation-guide.md b/docs/data-collector-implementation-guide.md index bfc2be3..1c4209b 100644 --- a/docs/data-collector-implementation-guide.md +++ b/docs/data-collector-implementation-guide.md @@ -33,8 +33,10 @@ codec's `when` over collector IDs is the allowlist. Every type below is declared in one file: [`core/collector-api/.../CollectorContracts.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt). -The only other file in `:core:collector-api` is the shared base class covered in -[section 6](#6-lifecycle). +The other four files in `:core:collector-api` are `SerializedCallbackCollector.kt` and +`SourceLifecycle.kt`, the shared base class and the source registration/teardown result types +covered in [section 6](#6-lifecycle); the generated `ProtocolEventContracts.kt`; and +`LatestValueRateGate.kt`, the tested rate gate that on-change collectors use. ### Plugin and instance @@ -141,6 +143,9 @@ sealed interface EmitResult { data object RejectedByAdmissionGate : EmitResult + /** The collector crossed its declared ID, schema, or maximum encoded-size boundary. */ + data object ContractViolation : EmitResult + data object StorageFailure : EmitResult } @@ -179,6 +184,9 @@ enum class AccessKind { RESEARCH_KEYBOARD_ENABLED, RESEARCH_KEYBOARD_SELECTED, ACCELEROMETER_HARDWARE, + GYROSCOPE_HARDWARE, + AMBIENT_LIGHT_HARDWARE, + PROXIMITY_HARDWARE, } data class AccessRequirement( @@ -188,7 +196,7 @@ data class AccessRequirement( ``` `AccessKind` deliberately mixes Android runtime permissions, special access grants, an -input-method selection state, and a hardware capability. They are all preconditions the +input-method selection state, and hardware capabilities. They are all preconditions the participant can see and, except for hardware, revoke. `required` is not a property of the collector; it is copied from the `required` flag the researcher set on that collector in the study configuration. @@ -321,10 +329,22 @@ The base class for callback-driven collectors is It marks all four lifecycle methods `final` and leaves you two: ```kotlin -protected abstract suspend fun registerSource() -protected abstract suspend fun unregisterSource() +protected abstract suspend fun registerSource(): SourceRegistrationResult +protected abstract suspend fun unregisterSource(): SourceTeardownResult ``` +Both return an explicit outcome rather than `Unit`, because a failure has to say whether the +Android source was left attached. `SourceRegistrationResult` is `Registered`, `Released(failure)` +when rollback proved nothing is attached, or `Uncertain(failure)` when it did not. +`SourceTeardownResult` is `Released` or `ReleasedWithFailure(failure)`, both of which promise the +callbacks are physically released or independently isolated; throwing instead leaves the source +uncertain, and the base class then refuses to register a second generation over it. Both types are +declared in +[`SourceLifecycle.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycle.kt), +alongside `registerSourceWithRollback`, which returns the registration result, and +`completeSourceTeardown`, which runs every teardown operation before rethrowing the first failure so +the collector can return a teardown result of its own. + Use it unless your source is a periodic query. What the base class does with each call: ### `start()` @@ -332,11 +352,13 @@ Use it unless your source is a periodic query. What the base class does with eac 1. Rejects a second start (`check(consumerJob == null)`) and rejects starting from any state other than `STOPPED` or `FAILED`. 2. Launches the single consumer coroutine on `Dispatchers.Default` in the runtime's scope. -3. Calls `registerSource()` inside a `sourceRegistered` guard that makes double registration +3. Calls `registerSource()` inside a `sourceState` guard that makes double registration a failure rather than a silent second listener. -4. On success sets `ACTIVE`. On failure it drains the consumer, clears the job, sets - `FAILED` / `SOURCE_REGISTRATION_FAILED`, and rethrows so the runtime can record - `COLLECTOR_START_FAILED`. The collector can be started again afterwards. +4. On success sets `ACTIVE`. On failure it sets `FAILED` / `SOURCE_REGISTRATION_FAILED` and + rethrows so the runtime can record `COLLECTOR_START_FAILED`. It drains the consumer and clears + the job only when the source is proven released, which is also the only case in which the + collector can be started again afterwards; an `Uncertain` registration leaves the consumer + running and blocks a restart. ### `pause()` @@ -446,8 +468,14 @@ draw a wrong conclusion about timing. | --- | --- | --- | | `Accepted(sequenceNumber)` | durably appended | nothing | | `RejectedByAdmissionGate` | outside a valid running window | drop it silently; this is normal at every pause and stop | +| `ContractViolation` | the draft is not this collector's ID, or it fails the catalog-derived event contract | set `FAILED` with a fixed reason code | | `StorageFailure` | the append failed | set `FAILED` with a fixed reason code | +`ContractViolation` is a defect in the collector, not a runtime condition: the runtime checks the +declared ID, payload schema, payload type, field set, field values, and worst-case encoded size +before it consults the admission gate, and returns without recording an incident or closing the +gate. Nothing about it improves on a retry. + `StorageFailure` is not recoverable by retrying. On the runtime side, `emit` force-closes the admission gate, records the `STORAGE_WRITE_FAILED` incident, and launches a fail-closed transition to `PAUSED` with reason `STORAGE_FAILURE`. The design choice is deliberate: @@ -516,6 +544,7 @@ Codes currently in the source: | `SOURCE_REGISTRATION_FAILED` | `SerializedCallbackCollector` | `registerSource()` threw during start or resume | | `SOURCE_UNREGISTRATION_FAILED` | `SerializedCallbackCollector` | `unregisterSource()` threw during pause or stop | | `CALLBACK_QUEUE_FULL` | `SerializedCallbackCollector` | the bounded queue rejected an event | +| `EVENT_CONTRACT_VIOLATION` | `SerializedCallbackCollector`, `network_usage.v1`, `usage_events.v1` | `emit` returned `ContractViolation` | | `STORAGE_WRITE_FAILED` | `SerializedCallbackCollector`, `network_usage.v1`, `usage_events.v1` | `emit` returned `StorageFailure` | | `WALL_CLOCK_NOT_FORWARD` | `network_usage.v1`, `usage_events.v1` | the wall clock did not advance past the coverage start | | `USAGE_ACCESS_REVOKED` | `network_usage.v1`, `usage_events.v1` | the platform query threw `SecurityException` | @@ -534,6 +563,11 @@ whichever wrote last. Runtime-level incidents (`COMMAND_REJECTED`, `RUNTIME_FAIL ## 10. The twelve built-in collectors +Twelve is the number a study configuration can choose from. The catalog holds thirteen entries: +the thirteenth, `interventions.v1`, is marked `"selectable": false` because the runtime rather +than a collector emits those events, and a study cannot select it. It is documented in the +[data dictionary](data-dictionary.md) instead. + Each entry states what the collector records and, as importantly, what its data cannot be used to claim. @@ -873,7 +907,7 @@ agree: | `core/access/.../AccessManager.kt` → `isGranted` | `getDefaultSensor(Sensor.TYPE_LIGHT) != null` | | `core/access/.../AccessManager.kt` → `settingsIntent` | `null` — there is no settings screen for hardware | | `app/.../MainActivity.kt` → `requestAccess` | `Unit` — hardware cannot be requested | -| `app/.../CollectorDashboard.kt` → `AccessKind.displayName` | a participant-readable label | +| `app/.../CollectorDashboard.kt` → `AccessKind.labelRes()` | a participant-readable label as an app string resource | Required missing hardware blocks enrollment. Optional missing hardware reports blocked access and starts only if hardware becomes available; it never substitutes another source. @@ -972,4 +1006,7 @@ Stated here rather than discovered later. review remains a security decision whenever Android APIs or build tooling change. - **`privacyClass` is declared but unread.** It documents the author's classification and drives no behaviour. -- **`displayName` is not shown to participants.** The dashboard lists collectors by ID. +- **`displayName` is not shown to participants.** `CollectorGrid` renders a localized name and + glyph that `CollectorSummary.summarize()` derives from the configuration type, out of the app's + own string resources. The descriptor's `displayName` reaches no participant-facing surface, so + the two can drift apart without anything failing. diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index 686e96a..0c12b79 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -72,6 +72,50 @@ A manual bundle is bounded by `storage.maximum_local_bytes`, which is why `resea `format` is bound into the bundle's cryptographic associated data, so a reader built for a different version fails to decrypt rather than silently misreading one: the authentication tag fails before any field is parsed. +### The transition history + +`transitions` is the study's lifecycle in order, one object per state change, from import up to the `state` the bundle reports. A study that has only been imported carries an empty array. + +```json +{ + "from": "RUNNING", + "reason": "PARTICIPANT_PAUSED", + "time": { + "wall_time_utc_millis": "1767225600000", + "monotonic_time_nanos": "12345678901234", + "boot_session_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9" + }, + "to": "PAUSED" +} +``` + +| Field | JSON type | Meaning | +| --- | --- | --- | +| `from` | string | The state before this change | +| `to` | string | The state after it | +| `reason` | string | Why it happened. Each reason has exactly one destination state. | +| `time` | object | The same three clocks, with the same caveats, as an event's `observed_time` | + +A state is one of `IMPORTED`, `CONFIG_VERIFIED`, `CONSENT_PENDING`, `ACCESS_SETUP`, `READY`, `RUNNING`, `PAUSED`, `COMPLETED`, or `WITHDRAWN`. The reasons and the state each one produces: + +| `reason` | `to` | +| --- | --- | +| `CONFIGURATION_SIGNATURE_VERIFIED` | `CONFIG_VERIFIED` | +| `CONSENT_REVIEW_OPENED` | `CONSENT_PENDING` | +| `CONSENT_ACCEPTED` | `ACCESS_SETUP` | +| `ACCESS_PREFLIGHT_PASSED` | `READY` | +| `PARTICIPANT_STARTED` | `RUNNING` | +| `PARTICIPANT_PAUSED` | `PAUSED` | +| `PARTICIPANT_RESUMED` | `RUNNING` | +| `PARTICIPANT_FINISHED_EARLY` | `COMPLETED` | +| `STUDY_DURATION_ELAPSED` | `COMPLETED` | +| `PARTICIPANT_WITHDREW` | `WITHDRAWN` | +| `STORAGE_FAILURE` | `PAUSED` | + +The history is checked before any plaintext is published: the first `from` is `IMPORTED`, each `from` equals the previous `to`, each `reason` agrees with its destination, the pair is a legal transition, and the last `to` equals `state`. A bundle whose history does not chain fails verification rather than decoding partially. + +Reconstruct the running and paused windows from `transitions` rather than from export times. `particeps-analysis` validates the history but does not materialize it: the typed Parquet dataset holds collector events only, so this array is read from the decrypted bundle JSON. + ### The event envelope Every event has the same shape regardless of collector. diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 6dfc0ba..5995c53 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -16,7 +16,7 @@ The release workflow reconstructs the same `.signing` configuration used locally ## The published site -**`Pages`** (`pages.yml`) deploys the web authoring surface on pushes to `main` that touch it, not on a tag. It builds a project site, and `BASE_PATH` comes from the repository name at build time, so the published path follows whatever the repository is called. No path is pinned in the source, which is why renaming the repository is enough on its own — and why it is not optional. Until the repository is renamed from `android-data-collector` to `particeps`, the tree says Particeps everywhere while the site still publishes at `https://jacoblincool.github.io/android-data-collector/`. Renaming it moves the site to `https://jacoblincool.github.io/particeps/` on the next deploy to `main`. +**`Pages`** (`pages.yml`) deploys the web authoring surface on pushes to `main` that touch it, not on a tag. It builds a project site, and `BASE_PATH` comes from the repository name at build time, so the published path follows whatever the repository is called. No path is pinned in the source, which is why renaming the repository is enough on its own — and why it was not optional. The repository has been renamed from `android-data-collector` to `particeps`, and the site publishes at `https://jacoblincool.github.io/particeps/`; the old path serves nothing. `BASE_PATH` is read at build time from the event payload, and that payload carries the repository name as it was when the event was created. A run queued before a rename therefore keeps building the old path however many times it is retried, because re-running replays the same event and redeploys the same artifact. @@ -45,7 +45,7 @@ Nothing published before `cool.jacoblin.particeps` can be updated in place, and The key was rotated to correct the certificate's subject, which named the pre-rename product and cannot be edited in place — a certificate is signed over its own subject, so changing it means issuing a new one. That was affordable only because every tag published to that point was a pre-1.0 release candidate and Developer Verification had not yet been registered. It stops being affordable the moment a real participant is running a released build, so it does not happen again. -Every tester installs the new APK fresh and uninstalls the old one themselves. Uninstalling takes the old app's encrypted storage with it, and there is no migration: the storage key is non-exportable, so data written by the pre-rename build can leave only through that build's own export, in the pre-rename formats, which current tooling does not read. A tester holding data worth keeping should export it before uninstalling and analyse it with the pre-rename tooling. State this in the release notes for the first post-rename tag; a tester expecting an in-place update will otherwise read a correct install as a failed one. +Every tester installs the new APK fresh and uninstalls the old one themselves. Uninstalling takes the old app's encrypted storage with it, and there is no migration: the storage key is non-exportable, so data written by the pre-rename build can leave only through that build's own export, in the pre-rename formats, which current tooling does not read. A tester holding data worth keeping should export it before uninstalling and analyse it with the pre-rename tooling. State this in the release notes for the first post-rename tag; a tester expecting an in-place update will otherwise read a correct install as a failed one. The notes for `v1.0.0-rc.4` do not state it, so it still has to reach testers another way. ## Android Developer Verification @@ -87,16 +87,21 @@ git tag -a v0.1.0 -m "v0.1.0" git push origin v0.1.0 ``` -Before tagging, update `version` and `date-released` in [`CITATION.cff`](../../CITATION.cff). Both fields are absent right now: every existing tag predates the rename and carries the old identity, so the file deliberately names no version rather than attributing one of those releases to Particeps. The first post-rename tag adds them back. +Before tagging, update `version` and `date-released` in [`CITATION.cff`](../../CITATION.cff). Both fields are present again and name `1.0.0-rc.4`, the first post-rename tag. They were absent before it because every tag up to `v1.0.0-rc.3` carries the old identity, and the file named no version rather than attributing one of those releases to Particeps. Keep both fields in step with the tag at every release. -### One-off: finishing the Particeps cutover +### One-off: the Particeps cutover -The rename is not a recurring step, but it is not finished when the code lands either. In order: +The rename is not a recurring step, and it is not finished when the code lands either. What has been done, in order: -1. Rename the GitHub repository from `android-data-collector` to `particeps`, and update its description, topics, and homepage. Do this only once `main` carries the renamed tree, because the badges, documentation links, and `BASE_PATH` in it all assume the new name. Repository secrets survive a rename and need nothing. -2. Dispatch Pages fresh (`gh workflow run pages.yml --ref main`) rather than re-running the run that straddled the rename, verify the published HTML carries no old-name asset paths, and reissue any join link or QR that pointed at the old one. -3. Register `cool.jacoblin.particeps` under Developer Verification with the existing certificate fingerprint, as above. -4. Cut the first post-rename tag, restore `version` and `date-released` in `CITATION.cff` as part of it, and say plainly in its release notes that it is a fresh install rather than an update. +1. The GitHub repository was renamed from `android-data-collector` to `particeps`, and its description, topics, and homepage were updated with it. The rename waited on `main` carrying the renamed tree, because the badges, documentation links, and `BASE_PATH` in it all assume the new name. Repository secrets survived it and needed nothing. +2. Pages redeployed under the new name. The published HTML carries no old-name asset paths, and an asset URL taken from it returns 200. +3. `v1.0.0-rc.4` was tagged as the first post-rename release, and `version` and `date-released` returned to `CITATION.cff` in it. + +What remains: + +- Register `cool.jacoblin.particeps` under Developer Verification against the current keystore fingerprint, as above. No package name this project has used was ever registered. +- The release notes for `v1.0.0-rc.4` are the generated changelog and do not say that it is a fresh install rather than an update. Say it to testers by some other route, and in the notes of the next tag. +- Reissue any join link or QR that pointed at the old Pages path, as the section above describes. This is per study rather than a single step: it is finished only when no issued link and no printed QR still points there. ## Pinned signers diff --git a/docs/participant-guide.md b/docs/participant-guide.md index b000347..08800f9 100644 --- a/docs/participant-guide.md +++ b/docs/participant-guide.md @@ -76,6 +76,18 @@ If the content does not match what the research team told you, if the contact de Two of these presses are not the same kind of thing. Continue (繼續) on the **Study** step is what moves the study forward internally, from imported through verified to awaiting consent. Continue (繼續) on the **Data** step only turns the page: the study's state does not change, because reading the list of sources and agreeing to it are one decision as far as the app is concerned, shown to you as two pages. One consequence is worth knowing: if you leave the app while you are on the Consent page and come back, you land on the Data page again rather than on the checkbox. +### If you were given a link or a QR code + +Step 2 has a second form. Instead of sending you a file, a research team can recruit you with a link beginning `particeps://join/v1`, or with a QR code that holds one. Opening the link, or scanning the QR code and opening what it offers, starts the app, and the app fetches the study file itself from the address written inside the link. + +The link names the file it expects: the address to fetch it from, the exact contents of the file, and the fingerprint of the key it is signed with. The app fetches that address once, over an encrypted connection, and checks that what arrived is byte for byte the file the link names and is signed by the key the link names. If either check fails, nothing is imported. If both pass, the file goes through the same checks section 1 describes, and the setup carries on from the **Study** step exactly as it does for a file you picked yourself. Nothing is collected until you press Start study (開始研究). + +The app fetches the file once and never goes back for another. A link cannot change a study you have already imported, and a research team that needs to change anything has to sign a new file and give you a new link for it. + +A link is accepted only when your phone holds no study at all. A study you have finished or withdrawn from but not yet deleted still counts, and so does a deletion that has not finished. When a link is refused for that reason the app shows the code `JOIN_ACTIVE_STUDY`, which reports the situation rather than a fault. + +Arriving this way also changes what checking the signer's fingerprint can prove. **Who signed the study**, below, explains how. + ### Where you are in the setup Under the study title, during setup, the app draws five dots left to right, joined by a line: Study, Data, Consent, Access, Start. A step you have finished is a filled check mark, the step you are on is a thick ring, and steps still ahead are faint thin rings. The line between them fills in as you go. The names are not printed — the dots give the position and the panel below gives the content — but a screen reader announces the name of the step you are on. Nothing on this row is a button; it tells you how much of the setup is left. @@ -104,6 +116,8 @@ Most studies show an instruction, then the reason for it in smaller grey type: **Seeing this is normal.** It is what the app shows for any study whose signer is not built into the app, which is most of them. It is not a warning that this particular study is fake. What it means is that the app cannot do this check for you, so you do it: your research team should have given you the fingerprint in the study information sheet, the consent document, or wherever they recruited you. Compare the two. If they match, the file came from whoever holds that key. If they do not match, or you were never given a fingerprint, stop and ask your research team before consenting. +**If you arrived by a link or a QR code, the fingerprint you were sent with it is not the one to compare against.** A `particeps://join/v1` link carries a signer fingerprint inside it, and the app refuses to import a file signed by any other key, so the fingerprint on this screen is the fingerprint that was in the link. Comparing the two shows only that the link agrees with itself: whoever composed the link chose both. To learn anything, the fingerprint you compare against has to reach you by a route the link did not — the study information sheet, the consent document, your research team's published page, or the team itself through details you already had. + The other possibility is a version of the app built by an institution to run only its own studies. It shows: > This app trusts this signer. @@ -427,6 +441,10 @@ Uninstalling the app or clearing its app data also destroys the local keys and d | --- | --- | | The interface is in a language you cannot read | Tap the globe at the top right of the header and pick a language; it is the same setting as Android Settings → Apps → Particeps → Language | | The configuration file will not import | Check the file, the client build/platform, and the study's validity period; do not modify the `.partcfg`, and contact the research team | +| `JOIN_LINK_INVALID` after opening a link or QR code | The link is not a complete `particeps://join/v1` link. Nothing was fetched and nothing was imported; ask your research team to send the link again rather than retyping or editing it | +| `JOIN_ACTIVE_STUDY` after opening a link or QR code | Not a fault: the phone still holds a study, so the link was not acted on. A study you have finished or withdrawn from but not yet deleted counts, and so does a deletion that has not finished. Open the link again once the phone holds no study | +| `JOIN_IMPORT_FAILED` after opening a link or QR code | The study file did not arrive intact or did not pass its checks: the fetch failed, what arrived was not the file the link names, it was not signed by the key the link names, or one of the checks in section 1 failed. Nothing was imported; quote the code to your research team | +| `STUDY_IMPORT_FAILED` | The app's own record that an import did not finish, whether it came from a link or from a file. Nothing was imported and nothing already on the phone changed | | Check this against the fingerprint your research team published (請與研究團隊公佈的金鑰指紋核對) | Ordinary for most studies; compare the fingerprint on the Configuration signature (設定檔簽章) block with the one your research team published, and if you do not have one, ask before consenting | | The fingerprint does not match the one you were given | Do not consent. Contact your research team through details you already had, not details taken from the study screen | | A required item in the access list is not granted | Tap that row, finish on the Android screen it opens, and return to the app; if you do not want to grant it, do not start | @@ -442,7 +460,7 @@ Uninstalling the app or clearing its app data also destroys the local keys and d | An `UPLOAD_…` code appears where the sent figure usually is | Collection carries on. Network, timeout, busy-server, and temporary-server errors retry automatically; other protocol/receipt errors can be terminal. Connect to Wi-Fi and charge the phone, then contact the research team and quote a persistent code | | Storage failure / paused | The app fails closed and stops accepting events; do not clear the app's data, contact the research team first, or export if you need to | -When the app has something to tell you, it shows a short code in capital letters in a red band directly under the header, beside a solid dot — `STORAGE_WRITE_FAILED`, `CONFIGURATION_IMPORT_FAILED`, `EXPORT_FAILED` and the like. Passing the code to your research team helps them diagnose the problem, and it contains none of your collected data. Two codes in that band are confirmations rather than problems: `EXPORT_COMPLETE` after an export finishes, and `LOCAL_DATA_DELETED` after a deletion. +When the app has something to tell you, it shows a short code in capital letters in a red band directly under the header, beside a solid dot — `STORAGE_WRITE_FAILED`, `CONFIGURATION_IMPORT_FAILED`, `EXPORT_FAILED` and the like. Passing the code to your research team helps them diagnose the problem, and it contains none of your collected data. Two codes in that band are confirmations rather than problems: `EXPORT_COMPLETE` after an export finishes, and `LOCAL_DATA_DELETED` after a deletion. A third, `JOIN_ACTIVE_STUDY`, is a refusal rather than a fault: the phone already holds a study, so the link was not acted on. ## 11. State reference @@ -470,9 +488,9 @@ If you need to describe your situation to your research team, the internal names ## 12. If you tested an earlier version of this app -This app used to be called Android Data Collector. As far as your phone is concerned, that app and Particeps are two completely separate apps. They sit side by side, and installing Particeps brings nothing across: no study, no consent, no collected events, and no export record moves from one to the other. The older app keeps running on your phone, with everything it already holds, until you remove it. What it can no longer do is deliver: it writes files in the older format, and if its study sends data automatically, the research team's server no longer accepts what it sends. +This app used to be called Android Data Collector. The identity Android uses to tell one app from another has moved twice since then, and the key the releases are signed with has been rotated once, so an earlier install is a separate application even where it already carries the name Particeps. As far as your phone is concerned, that older app and this one are two completely separate apps: the older install cannot update into this one, and the rotated signing key would refuse the update even if the identity had stayed the same. They sit side by side, and installing Particeps brings nothing across: no study, no consent, no collected events, and no export record moves from one to the other. The older app keeps running on your phone, with everything it already holds, until you remove it. What an Android Data Collector install can no longer do is deliver: it writes files in the older format, and if its study sends data automatically, the research team's server no longer accepts what it sends. -**Ask your research team before you uninstall the older app.** Uninstalling it destroys the key it keeps on your phone, and after that the encrypted data it collected cannot be read by anyone — not by you, not by them. There is no recovery and no backup. If they want what the older app collected, export it from that app while it is still installed and send them the file; tell them it came from the older version, because they need the older tools to open it. Only then uninstall. +**Ask your research team before you uninstall the older app.** Uninstalling it destroys the key it keeps on your phone, and after that the encrypted data it collected cannot be read by anyone — not by you, not by them. There is no recovery and no backup. If they want what the older app collected, export it from that app while it is still installed and send them the file; tell them which version it came from, because an export written by an Android Data Collector install needs the older tools to open it. Only then uninstall. If your study uses the research keyboard, you have to enable it and select it again for Particeps. Android treats it as a different keyboard, so the setting you made for the older app does not carry over. Section 4 describes the two steps. diff --git a/docs/researcher-guide.md b/docs/researcher-guide.md index 88d791f..51e4304 100644 --- a/docs/researcher-guide.md +++ b/docs/researcher-guide.md @@ -741,6 +741,11 @@ OEM hardware: - Consent; every required and optional access; behaviour after a denial; revoking access mid-study. - Start, pause, resume, finish, and withdraw for every configured collector. +- The daily status reminder: that it arrives, that it says the study is paused after a pause and + collecting again after a resume, and that finishing or withdrawing stops it. Reminders are a + day apart, so a pilot that runs for an afternoon will not show you one. If your study schedules + no interventions, grant notification access yourself first — the access step does not ask for + it, and without it nothing is posted. - Two exports and two successful decryptions from each of `RUNNING`, `PAUSED`, `COMPLETED`, and `WITHDRAWN`. - If the study uploads: the consent step's upload block against your consent document, a @@ -782,6 +787,28 @@ without the list of sources having been on screen. Afterwards participants can p finish early, withdraw, export repeatedly, and delete local data; the irreversible ones ask for confirmation. See [`participant-guide.md`](participant-guide.md) for what they are told. +From the start press onward the app posts one status reminder a day, for as long as the study is +`RUNNING` or `PAUSED`. It is a low-importance notification — no sound — whose title is the +application's own name rather than your study's, and whose single line says either that collection +is still running or that the study is paused and since when. It carries no collector names, no +counts, and nothing you wrote: it arrives every day for the study's whole duration, on a lock +screen anyone holding the phone can read. The paused half is why it exists — a pause changes +nothing else on the phone, so a study a participant meant to resume can sit collecting nothing for +weeks with nothing saying so. + +Plan participant contact around it. It is not one of your interventions — the app posts it on its +own, and no configuration field switches it off, rewords it, or adds to it — and the first one +arrives about a day after the start press. Starting or stopping collection retracts a reminder +already on screen rather than posting a replacement, so a paused study is never left asserting that +it is still collecting; finishing, completing on the duration deadline, and withdrawing cancel the +schedule and clear the standing notification. It needs notification access, which the access step +requires only when your study schedules interventions, so in a study without them it reaches only +the participants who granted notifications for some other reason. None of that makes the reminder +a guarantee that a participant has been reminded: its timing is best effort rather than an exact +alarm, a force stop blocks it until the app is opened again, and a participant can turn its +channel off in Android's notification settings or revoke notification access, either of which +stops the reminder without stopping the study. + Researchers must not: - ask a participant to skip a disclosure screen, or describe optional access as required; diff --git a/docs/system-design.md b/docs/system-design.md index 1fb8dd9..cb48915 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -109,8 +109,16 @@ of sources having been shown. Once setup is over the header shows the study stat instead, and the panel becomes collector health, an event meter, and the lifecycle controls. `CollectorSummary.kt` is what the data step renders: one template per collector type, filled from -the signed configuration's own parameters, plus a fixed per-collector line naming what that source -cannot see. It is app-authored text with no configuration field behind it — see +the signed configuration's own parameters. A summary carries a glyph, a name, that one detail line, +and whether the collector is optional; the panel shows the glyph, the name, an `Optional` tag when +the configuration does not mark the collector required, and the detail. Several of the detail +templates end in a limit — battery context is not battery health or hardware identity, a time zone +is not a location claim, rotation has no orientation or activity inferred from it — but that +clause is part of the detail text rather than a separate field, and the collectors whose template +does not carry one state nothing about what the source cannot see. The per-collector statement of +what a source cannot establish is the table in [`researcher-guide.md`](researcher-guide.md), which +is documentation for the researcher designing the study and not something the app renders. The +summaries are app-authored text with no configuration field behind them — see [`threat-model.md`](threat-model.md). Every participant-facing string is a resource; none is written into Kotlin. The app ships English @@ -493,6 +501,27 @@ the same way. re-verifies the signed envelope and loads the encrypted metadata. Collectors are constructed on every initialization, but the admission gate, collector activation, and the foreground service are restored only when the persisted state was `RUNNING`. +- `DailyStatusWorker` posts one low-importance notification a day while the study is `RUNNING` or + `PAUSED`. It says either that collection is still running or that the study is paused and since + when, and nothing else: no counts, no collector names, and the title line is the application's own + name rather than the study title, because this arrives every day and a lock screen is readable by + whoever is holding the phone. One notification tag, so today's reminder replaces yesterday's. A + run in any other state, or with no configuration, posts nothing. Without `POST_NOTIFICATIONS` the + run succeeds without posting rather than retrying. +- `AndroidStudyWorkScheduler.scheduleDailyStatus` enqueues it as unique periodic work with a one-day + period and a one-day initial delay, from `schedule` when a study starts and from + `reschedulePendingWork` whenever a session initialises. Periodic rather than a chain: a day is far + above the 15-minute floor, so nothing is silently clamped, and the platform re-establishes + periodic work across reboots. `ExistingPeriodicWorkPolicy.KEEP`, so a session initialising again + does not push the next reminder a full day away. +- The schedule is deliberately not cancelled on pause, since a paused study is the case the + reminder exists for; `cancelCollectionWork` cancels both the schedule and any standing + notification when the study reaches a terminal state — finished early, completed at its + deadline, or withdrawn — and deleting local data cancels it as well. Starting or stopping + collection retracts a standing reminder without posting a replacement, because it states a state + that has just stopped being true and the next daily run posts the truth. Since pause stops the + foreground service and cancels visible prompt notifications, this is the only notification that + appears while a study is paused. - Each intervention combines a reusable action with one or more triggers. Actions are localized notifications or localized native surveys. Triggers are one-time offsets, repeating intervals, daily local times, or signed random local windows. Relative triggers declare whether elapsed diff --git a/docs/threat-model.md b/docs/threat-model.md index e929df8..0dbb90c 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -199,6 +199,10 @@ A build that pins its signers removes this exposure for the studies it accepts, - The bundle header exposes the researcher key ID in cleartext, and the suggested export filename contains the study ID and an export timestamp. Anyone handling the file can tell that this person participated in that study. - Size discloses roughly how much data was collected. The same is true on the device: event segment file sizes and modification times leak collection volume and timing to anyone with filesystem read access, without any decryption. +**What a notification discloses to someone holding the phone.** Three kinds of notification are visible without any decryption. The ongoing collection notification is present only while collection is actually running, and it carries the study title on its second line. An intervention notification is posted only while the study is running, one per occurrence, and its title and message are the researcher's own text out of the signed configuration, so a survey prompt is on the screen in whatever words the researcher chose; its channel is `IMPORTANCE_DEFAULT`, so unlike the other two it alerts. The third is the daily status reminder: once a study has started, `DailyStatusWorker` posts one notification a day for as long as the study is either collecting or paused, and it is the only notification the app shows while a study is paused, because pausing stops the foreground service and cancels every intervention notification. Its title line is the application's own name; its body says either that collection continues or that the study is paused and since when. It carries no study title, no researcher name or contact, no counts, and no collector names, so a bystander learns that this phone runs Particeps and which of those two states it is in — not which study, not what that study records, and not who is running it. The channel is `IMPORTANCE_LOW`, so the reminder is silent; one notification tag is reused, so today's replaces yesterday's rather than accumulating; and if the participant never granted the notification permission, nothing is posted at all. The app sets no lockscreen visibility on either the channel or the notification, so the device's own setting for notification content on a locked screen is what decides whether the text can be read without unlocking. + +The residual risk is that the existence and the duration of a study become visible to whoever holds the phone. That is the cost of the reminder rather than a defect in it — a pause that nothing mentions is how a study meant to run for a fortnight quietly records nothing — but it is a standing daily disclosure for the study's whole length, and it is the one surface that goes on disclosing after a participant has paused. Starting or stopping collection retracts a reminder that is already showing, and finishing or withdrawing cancels both the schedule and the notification, so none of it outlives the study. Android's per-channel notification settings let a participant turn the reminder off; that removes the reminder, not the study. + **A compromised or hostile upload endpoint.** An endpoint that is taken over, misconfigured, or logging more than intended still cannot read a bundle without the researcher private key. It does learn the untrusted bundle metadata above, including a stable configuration digest that can link submissions from the same issued artifact. It can refuse delivery indefinitely; the device retains the data and collection continues. Conversely, an endpoint can fabricate a matching seven-field `201`/`200` receipt without keeping the body. That can advance the watermark and eventually make those events reclaimable under storage pressure. Receipt matching makes response loss and accidental mismatch safe; it cannot prove remote durability against the server itself. Treat the endpoint as study infrastructure, keep the decryption key off it, minimize logs, and state its operator in consent material. The receiver ingress has no participant authentication or device attestation. An attacker can submit @@ -218,7 +222,7 @@ cannot see text, but its timing and within-key position data are behaviourally d **Configuration replay and clock manipulation.** The signed envelope has no nonce and no device binding, so the same configuration can be imported on any number of devices until it expires. Validity is checked against the device wall clock, so a participant who moves their clock backwards can revive an expired configuration. Keep validity windows short; a multi-year window makes both worse. -**No signer revocation.** There is no revocation list, rotation protocol, or kill switch at any layer. A leaked study signing key can mint configurations that any build with an empty anchor map accepts, and configurations already signed with it stay valid until they expire; a short validity window is the only control. Where a build does pin signers, that set is fixed and auditable at build time, and retiring one of those keys requires shipping a new APK. +**No signer revocation.** There is no revocation list, no in-protocol rotation, and no kill switch at any layer. A leaked study signing key can mint configurations that any build with an empty anchor map accepts, and configurations already signed with it stay valid until they expire; a short validity window is the only control. Rotating a study signing key is possible but entirely manual: the researcher generates a new key, puts it in the `signer` block of a new configuration, re-signs, and republishes the fingerprint through the channel that recruited the participants, as the [researcher guide](researcher-guide.md) sets out. No app release is involved, and nothing on a device learns that the old key was retired, so a rotation governs configurations signed after it and nothing already issued. Where a build does pin signers, that set is fixed and auditable at build time, and retiring one of those keys requires shipping a new APK. **Key loss.** Losing the researcher HPKE private key makes every export for that configuration permanently unreadable. There is no escrow and Protocol v1 names exactly one raw recipient key, so multi-recipient encryption is unavailable. Losing the device's Keystore key — through device wipe, uninstall, or clearing app data — destroys all un-exported local data. Neither case has a recovery path. diff --git a/protocol/v1/README.md b/protocol/v1/README.md index 7c361f7..9c29289 100644 --- a/protocol/v1/README.md +++ b/protocol/v1/README.md @@ -304,7 +304,11 @@ one while accepting the other. Absence of a vector is not permission to accept a encoding. The join-link corpus is consumed by Kotlin and TypeScript, the two implementations that create or -open join links. Python analysis has no join-link entrypoint and does not interpret that corpus. +open join links. Python analysis has no join-link entrypoint and never parses a join link. +`tools/validate_protocol_vectors.py` does read the corpus, but only to check the fixtures +themselves: closed-world shape, corpus identity, digest and fingerprint spelling, ASCII, and the +4,096-byte limit. That is not an implementation of the join-link grammar, and it proves nothing +about the profile rules above. Validate the checked-in sources with: From 54e040160fd578639ded9420112d2421058c8e15 Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 05:14:58 +0800 Subject: [PATCH 7/8] Give the release history one home, and retire component boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename was told at full length in four documents, about a thousand words between them, because no document owned "what changed and what it asks of you". CHANGELOG.md owns it now. The README and the release guide keep the paragraph their own reader needs and link for the rest; the participant guide keeps its own, because a participant should not have to open a changelog. component-boundaries.md is retired. Every contract in it restated a row of the system design's module table, its invariants restated that document's security and privacy invariants, and its ASCII dependency sketch was a worse version of the mermaid diagram three sections earlier. Nothing was lost: the two claims worth checking — that a collector cannot see storage or the runtime, and that reclaiming runs between 80% and 60% of quota — were already stated there, in more detail. The system design listed six of the seven researcher-tools commands. Same omission the README had, in a document the earlier correctness pass did not cover: personalize was missing. --- CHANGELOG.md | 61 +++++++++++++++ README.md | 18 +++-- docs/component-boundaries.md | 82 --------------------- docs/data-collector-implementation-guide.md | 2 +- docs/maintainers/release.md | 23 ++++-- docs/researcher-guide.md | 1 - docs/system-design.md | 2 +- 7 files changed, 92 insertions(+), 97 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 docs/component-boundaries.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dea6642 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +What changed between releases, and what each change asks of someone who already installed one. + +This project is pre-1.0. Every release so far is a release candidate, and each one below changed +something that a device treats as identity — the application ID, the file formats, or the signing +certificate. None of them can update an earlier install in place. That is stated once here rather +than in each document that touches it. + +## Unreleased + +- The application ID moved from `cool.linc.particeps` to `cool.jacoblin.particeps`, and the release + signing key was rotated so that the certificate names Particeps rather than the pre-rename + product. Either change alone stops a device accepting the build as an update; both apply. +- The status line reports when a pause started and how long it has lasted. +- One low-importance notification a day states whether the study is still collecting, or is paused + and since when. It names the application, never the study, so it discloses nothing to someone + reading a lock screen. Starting or stopping collection retracts a standing one. + +**Coming from `v1.0.0-rc.4`:** uninstall it. Its data cannot be migrated, and its exports are in the +current format, so export anything worth keeping before you remove it and current tooling will read +it. See [the participant guide](docs/participant-guide.md) for the participant-facing version. + +## v1.0.0-rc.4 — 2026-08-06 + +The project was renamed from Android Data Collector to Particeps. Application ID +`cool.linc.particeps`. + +Protocol v1 keeps `schema_version: 1` and gains no second dialect; every identity string was +replaced at once: + +| | Was | Now | +| --- | --- | --- | +| Signed configuration | `.adccfg`, `ADCCFG01` | `.partcfg`, `PTCCFG01` | +| Encrypted export | `.adcexp`, `ADCEXP01` | `.partexp`, `PTCEXP01` | +| Join URI | `adc://join/v1` | `particeps://join/v1` | +| Bundle format | `research-bundle-v1` | `particeps-research-bundle-v1` | +| Upload media type | `application/vnd.adc.research-bundle` | `application/vnd.particeps.research-bundle` | +| Upload headers | `X-ADC-*` | `X-Particeps-*` | +| Offline analysis | `adc-analysis` | `particeps-analysis` | + +The retired spellings are rejected inputs rather than an older dialect. Every implementation fails +closed on them, and the shared conformance corpus carries a vector for each. + +**Coming from `v1.0.0-rc.3` or earlier:** uninstall it first — it is a different application ID and +runs alongside. Its exports are `.adcexp` files that current tooling refuses, so anything worth +keeping has to be exported and analysed with the pre-rename tooling before you remove it. + +## v1.0.0-rc.3 — 2026-08-05 + +Application ID `cool.linc.androiddatacollector`. The R2 ciphertext receiver, the offline +verification and Parquet pipeline, immutable signed join links, and the battery, temporal-context, +gyroscope, ambient-light and proximity collectors. + +## v1.0.0-rc.2 — 2026-08-03 + +Application ID `cool.linc.androiddatacollector`. + +## v1.0.0-rc.1 — 2026-08-02 + +First release candidate. Application ID `cool.linc.androiddatacollector`. diff --git a/README.md b/README.md index 5957832..6c6e924 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ flowchart LR | `:researcher-tools` | Ed25519 and HPKE keys, canonicalise, sign, verify, decrypt CLI | | `receiver/` | One bounded Protocol v1 upload POST, immutable ciphertext writes, and canonical receipts | -Platform-independent modules contain no `android.*` imports, which keeps the domain logic testable on the JVM. [Component boundaries](docs/component-boundaries.md) documents the contracts. +Platform-independent modules contain no `android.*` imports, which keeps the domain logic testable on the JVM. [System design](docs/system-design.md) documents the module contracts. New contributors should treat [`protocol/v1`](protocol/v1/README.md) as the normative wire contract, the [collector catalog](protocol/v1/collector-catalog.json) as the schema source, and [`docs/system-design.md`](docs/system-design.md) for how the modules fit together. Trace one path through the [configuration codec](core/study-definition/src/main/kotlin/cool/jacoblin/particeps/core/definition/StudyConfigurationCodec.kt), [signed envelope](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/SignedConfiguration.kt), [bundle exporter](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchExport.kt), [bundle verifier](core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt), [single-entry outbox](app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), [HTTP adapter](app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt), [receiver handler](receiver/src/index.ts), and the offline [`particeps-analysis`](particeps-analysis/README.md) pipeline. The join path is similarly short: [Web authoring](web/src/lib/particeps/join.ts), [shared parser](core/protocol/src/main/kotlin/cool/jacoblin/particeps/core/protocol/JoinLink.kt), [Android staging](app/src/main/kotlin/cool/jacoblin/particeps/platform/JoinArtifactDownloader.kt), [intent entry](app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt), then the existing [session import](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt). The [outbox](app/src/test/kotlin/cool/jacoblin/particeps/platform/FileUploadOutboxTest.kt), [uploader](app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt), and [receiver](receiver/tests/receiver.test.ts) tests make crash/replay and receipt semantics executable. Receiver deployment and R2 operations start at [`receiver/README.md`](receiver/README.md), and the Collector capability policy lives under [`assurance`](assurance/README.md). @@ -197,12 +197,12 @@ runtime, session, and app policy tests make each boundary executable. | Document | For | | --- | --- | +| [Changelog](CHANGELOG.md) | What changed between releases, and what it asks of an existing install | | [Researcher guide](docs/researcher-guide.md) | Designing, signing, deploying, and analysing a study | | [Data dictionary](docs/data-dictionary.md) | Every field on every event, per collector | | [Participant guide](docs/participant-guide.md) | People taking part in a study | | [Collector implementation guide](docs/data-collector-implementation-guide.md) | Writing a new collector | | [System design](docs/system-design.md) | The implemented v1 architecture in full | -| [Component boundaries](docs/component-boundaries.md) | Module contracts and invariants | | [Threat model](docs/threat-model.md) | Trust assumptions and limitations, for ethics review | | [Normative Protocol v1](protocol/v1/README.md) | JCS, keys, join URI, binary framing, bundle document, upload, receipt, and conformance corpora | | [Collector capability policy](assurance/README.md) | Static source, bytecode, and dependency boundaries for collectors | @@ -214,11 +214,19 @@ runtime, session, and app policy tests make each boundary executable. New collectors are the main contribution path — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [implementation guide](docs/data-collector-implementation-guide.md). To report a security or privacy issue, see [SECURITY.md](SECURITY.md) rather than opening a public issue. -## Coming from a pre-rename release candidate +## Coming from an earlier release candidate -This project was called Android Data Collector through its early 1.0 release candidates; every tag published so far carries an identity the current build no longer uses. The Android `applicationId` has moved twice since — `cool.linc.androiddatacollector`, then `cool.linc.particeps`, now `cool.jacoblin.particeps` — and the release signing key has been rotated. Android treats each of those as a different application, and the new certificate would refuse the update even if it did not. There is no upgrade and no migration: installing Particeps does not see, move, or convert anything belonging to an installed pre-rename build, which keeps running under its own name until it is removed. Uninstalling it takes its Keystore keys with it, and every study, encrypted event segment, undelivered outbox bundle, and imported configuration on that install becomes unrecoverable — cloud backup and device transfer were already disabled for this app, so nothing is held anywhere else. Export whatever is still wanted before uninstalling, and re-enable the research keyboard under the new app if a study uses it. +Every release candidate published so far runs under a different application ID from the current +build, and the release signing key has since been rotated. Android treats each as an unrelated +application, so there is no upgrade and no migration: the older build keeps running under its own +name until it is removed, and uninstalling it destroys its Keystore key and everything encrypted +under it. Export whatever is still wanted first. -Artifacts produced before the rename are unsupported for final Protocol v1. A `.adccfg` configuration, a `.adcexp` export, an `ADCCFG01` or `ADCEXP01` container, a `research-bundle-v1` document, an `adc://join/v1` link, and an upload carrying `application/vnd.adc.research-bundle` or any `X-ADC-*` header are invalid input to every current implementation and are rejected exactly as random bytes are. Re-sign configurations with the current tooling and re-run any pilot; there is no converter, and none will be added. +Artifacts from before the rename are unsupported. A `.adccfg`, a `.adcexp`, an `ADCCFG01` or +`ADCEXP01` container, a `research-bundle-v1` document, an `adc://join/v1` link, and an upload +carrying `application/vnd.adc.research-bundle` or an `X-ADC-*` header are all invalid input to every +current implementation. There is no converter. [CHANGELOG.md](CHANGELOG.md) says which release +carries which identity. ## Status diff --git a/docs/component-boundaries.md b/docs/component-boundaries.md deleted file mode 100644 index 70d9f3c..0000000 --- a/docs/component-boundaries.md +++ /dev/null @@ -1,82 +0,0 @@ -# Component boundaries - -The participant app is organized around the eight responsibilities below, not around Android -entry points. Each contract states what a module owns and, where it matters, what it never -sees. The dependency direction in `settings.gradle.kts` and the module build files is the -enforcement mechanism. - -New contributors should read this with the [normative Protocol v1 contract](../protocol/v1/README.md), -the [collector catalog](../protocol/v1/collector-catalog.json), the -[`assurance`](../assurance/README.md). The concrete upload seam is deliberately short: -[StudyUploader](../core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt), -[FileUploadOutbox](../app/src/main/kotlin/cool/jacoblin/particeps/platform/FileUploadOutbox.kt), -[OkHttpStudyUploader](../app/src/main/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploader.kt), -and their neighbouring tests. - -```text -participant UI -> study application -> study domain - |-> collector runtime -> collector API <- collector features - |-> study store port <- encrypted Android storage -Android host/access/work/recovery -> study application -researcher tools -> study definition + signed protocol + export format -``` - -## Contracts - -- `:core:model` owns finite study metadata, events, state transitions, and the `StudyStore` - port. It never owns an unbounded event list. -- `:core:study-definition` owns the strict, closed-world study schema and its canonical codec. -- `:core:protocol` owns the signed envelope, immutable join-URI parser, and trust verification. -- `:core:collector-api` is the only runtime-facing core API a collector feature depends on; a - collector also depends on `:core:study-definition` for its closed typed configuration. Hardware - sensor modules may additionally use the narrow `:collector:sensor-common` lifecycle helper. - `:core:collector-api` owns collector lifecycle, health, event admission contracts, capabilities, - and serialized callback delivery. Its `CollectorContext` hands a collector a coroutine scope, an - `EventSink`, and a clock — no store, no state machine, no scheduler, no exporter. -- `:core:experiment-runtime` owns command serialization, state transitions, admission, and - collector supervision. It is platform independent. -- `:core:study-application` owns study use cases and coordinates injected storage, access, - foreground-host, scheduling, export, and upload ports. The `StudyUploader` port takes a - sequence window and returns a verified receipt; durable staging and HTTP live in `:app`. -- Android entry points and adapters live in `:app`; they do not duplicate recovery or study - policy. -- `:core:storage`, `:core:export`, and `:core:crypto` implement the encrypted data boundary - without exposing cryptographic internals transitively. - -## Invariants - -- Events are appended and never rewritten, and the retained window is sequence-contiguous. - `StudyMetadata` requires `nextSequenceNumber == eventCount + 1` on the lifetime count, and - `retainedFromSequence` marks the lowest sequence still on disk. A read rejects a gap between - surviving segments, a segment index mismatch, or a non-contiguous sequence number rather - than skipping past it. Survivors need not start at index 1 or at sequence 1: reclaiming - removes whole leading segments and never reuses an index or a sequence number. Runtime - snapshots carry bounded metadata — counters, the transition history, and the last event per - collector — never the event log. -- A bundle's authenticated JCS document declares exactly the contiguous window it holds. A - participant export takes the retained window and may scale to the local quota. Automatic upload - selects a bounded exact window, then durably stages one complete ciphertext bundle plus its - manifest before HTTP. At most one entry exists; reboot, process death, retry, and response loss - reuse its bundle ID, digest, range, length, and bytes. -- The upload watermark advances only on `201 Created` or exact-replay `200 OK` with a canonical - seven-field receipt matching the outbox manifest, and never backwards. Redirects, `202`, other - statuses, and malformed or mismatched receipts cannot commit. A confirmed delivery is the only thing that makes - local data reclaimable. Reclaiming starts only above 80% of the study's quota, stops at 60% of - it, and takes whole leading segments at or below the watermark. Undelivered data is never - released to make room. -- Study metadata is self-sufficient: opening a study validates framing and sequence contiguity - from the plaintext frame headers and reads `lastEvents` from the metadata, so load cost is - linear in frames rather than in bytes decrypted. Event payloads are authenticated when read; - the one exception is exact append-journal recovery, which authenticates only the durable tail. - Reading a range decrypts only that range and seeks past the frames below it. -- Configuration decoding stays canonical, strict, and compile-time allowlisted. There is no - legacy or fallback reader. -- The collector catalog is the shared schema source, but not a runtime plugin mechanism. Runtime - validates payload schema and `maximumEncodedEventBytes`; the Collector capability policy - constrains source, bytecode, and dependencies in CI. -- One process-scoped study session owns recovery and runtime lifetime. Android receivers, - workers, services, and UI delegate to it. - -Collector-side detail lives in the -[Collector implementation guide](data-collector-implementation-guide.md); the full module map -is in [System design](system-design.md). diff --git a/docs/data-collector-implementation-guide.md b/docs/data-collector-implementation-guide.md index 1c4209b..1aa297d 100644 --- a/docs/data-collector-implementation-guide.md +++ b/docs/data-collector-implementation-guide.md @@ -5,7 +5,7 @@ a new collector without breaking the pause, privacy, and storage invariants that of the system depends on. Read [System design](system-design.md) first for the module map, and -[Component boundaries](component-boundaries.md) for the responsibility split. This document +[System design](system-design.md) for the responsibility split. This document covers only the collector side of that boundary. The [normative Protocol v1 contract](../protocol/v1/README.md) defines the enclosing configuration and bundle. Its machine-readable schema source is the [Protocol v1 collector catalog](../protocol/v1/collector-catalog.json); the generated Kotlin diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 5995c53..a4a6ec4 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -39,13 +39,22 @@ Two unrelated keys are involved, and they must never be interchanged. Losing the Android signing private key means no future build can update a directly installed app under the same identity. Keep an offline, encrypted backup. -### The rename is not an upgrade path - -Nothing published before `cool.jacoblin.particeps` can be updated in place, and two independent things guarantee that. The `applicationId` moved twice — `cool.linc.androiddatacollector`, then `cool.linc.particeps`, now `cool.jacoblin.particeps` — and a device identifies an installed application by that value, so Android treats each as unrelated to the others. The release signing key was then rotated as well, which changes the certificate a device compares on update. Either alone would be enough; both together mean there is no version of this app on anyone's phone that the current build can replace. - -The key was rotated to correct the certificate's subject, which named the pre-rename product and cannot be edited in place — a certificate is signed over its own subject, so changing it means issuing a new one. That was affordable only because every tag published to that point was a pre-1.0 release candidate and Developer Verification had not yet been registered. It stops being affordable the moment a real participant is running a released build, so it does not happen again. - -Every tester installs the new APK fresh and uninstalls the old one themselves. Uninstalling takes the old app's encrypted storage with it, and there is no migration: the storage key is non-exportable, so data written by the pre-rename build can leave only through that build's own export, in the pre-rename formats, which current tooling does not read. A tester holding data worth keeping should export it before uninstalling and analyse it with the pre-rename tooling. State this in the release notes for the first post-rename tag; a tester expecting an in-place update will otherwise read a correct install as a failed one. The notes for `v1.0.0-rc.4` do not state it, so it still has to reach testers another way. +### No release so far can be updated in place + +[CHANGELOG.md](../../CHANGELOG.md) lists which release carries which application ID. Two independent +things put every one of them out of reach of the current build: the application ID moved twice, and +a device identifies an installed application by that value; and the release signing key was rotated, +which changes the certificate a device compares on update. + +The key was rotated to correct the certificate's subject, which named the pre-rename product. A +certificate is signed over its own subject, so changing it means issuing a new one. That was +affordable only because every tag published to that point was a pre-1.0 release candidate and +Developer Verification had not yet been registered. It stops being affordable the moment a +participant is running a released build, so it does not happen again. + +Say this in the release notes. A tester expecting an in-place update reads a correct install as a +failed one, and the notes for `v1.0.0-rc.4` do not say it, so it still has to reach testers another +way. ## Android Developer Verification diff --git a/docs/researcher-guide.md b/docs/researcher-guide.md index 51e4304..3025499 100644 --- a/docs/researcher-guide.md +++ b/docs/researcher-guide.md @@ -1052,4 +1052,3 @@ substitute for any of this. - [`data-dictionary.md`](data-dictionary.md) — field-level reference for every collector. - [`threat-model.md`](threat-model.md) — trust assumptions, current protections, and their limitations. Written to be attached to an ethics submission. - [`system-design.md`](system-design.md) — architecture and data flow. -- [`component-boundaries.md`](component-boundaries.md) — module responsibilities. diff --git a/docs/system-design.md b/docs/system-design.md index cb48915..0afd286 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -87,7 +87,7 @@ flowchart LR | `:core:export` | Authenticated JCS/AES-GCM bundle construction over an exact sequence window, HPKE wrapping, closed-world streaming bundle verification, provenance, and strict receipt parsing | | `:collector:*` | One independent module per data source | | `:collector:sensor-common` | Listener-thread ownership shared only by raw Android hardware-sensor collectors; no schema, inference, or storage policy | -| `:researcher-tools` | CLI for Ed25519/HPKE key generation, canonicalization, signing, configuration checking, and decryption (`signing-keygen`, `hpke-keygen`, `canonicalize`, `sign`, `check-config`, `decrypt`) | +| `:researcher-tools` | CLI for Ed25519/HPKE key generation, canonicalization, signing, configuration checking, and decryption (`signing-keygen`, `hpke-keygen`, `canonicalize`, `sign`, `personalize`, `check-config`, `decrypt`) | A collector feature depends on `collector-api`, `study-definition`, and, for raw hardware listener ownership only, `collector:sensor-common`. This is the sole collector-to-collector dependency and From 1c3cdddb5581dba4baac80b663cad135a434874b Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Fri, 7 Aug 2026 05:32:06 +0800 Subject: [PATCH 8/8] Give each repeated explanation one owner, and split the hardest sentences The upload receipt contract was told in full in nine documents; it is now in the protocol specification, with a sentence of consequence and a link everywhere else. The same treatment for the HPKE and PTCEXP01 framing, the mechanism under signer trust, the demonstration-key warning, and the per-collector negative field lists: each has one owner, and the documents that used to repeat it keep only what their own reader needs. The caveat is not the mechanism, and the caveats all stay. "A signature proves the file is unchanged since it was signed, not who wrote it" appears in five documents on purpose, because five different readers need it where they are standing. What moved is the explanation of how the key travels in the signed bytes and how the fingerprint is derived. Sentences that were both long and clause-dense fall from 96 to 38, and the longest in the set from 78 words to 57. Nothing was shortened by deleting content: the count of sentences rises, because the fix for a 78-word sentence is three sentences rather than a shorter one. Net words barely move. The correctness pass before this added a section the participant guide was missing and three descriptions of the daily reminder, and splitting a sentence costs a few words. Deduplication paid for both. --- CONTRIBUTING.md | 27 +- README.md | 76 ++---- SECURITY.md | 6 +- docs/data-collector-implementation-guide.md | 128 +++++----- docs/data-dictionary.md | 45 ++-- docs/maintainers/release.md | 30 +-- docs/participant-guide.md | 93 ++++--- docs/researcher-guide.md | 267 ++++++++++---------- docs/system-design.md | 260 +++++++++---------- docs/threat-model.md | 76 +++--- particeps-analysis/README.md | 29 +-- protocol/v1/README.md | 86 ++++--- receiver/README.md | 35 +-- researcher-tools/examples/README.md | 16 +- tools/retired_identity_audit.py | 4 +- web/CONTRACT.md | 34 +-- 16 files changed, 587 insertions(+), 625 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f047e96..8b0af60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,9 +16,9 @@ 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 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. +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. +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`. That is what makes sequence numbers contiguous and monotone, quota accounting correct, and a bundle able to declare the exact window it carries. The boundary is checked, not merely reviewed. `tools/collector_assurance.py` reads `assurance/collector-policy.json` and fails CI on a forbidden import, a forbidden Gradle dependency, or a forbidden symbol in a compiled class. What it does not read is the manifest, so a collector module can still declare a permission or a component that nothing stops — that gap is tracked in issue #11. @@ -46,12 +46,35 @@ Requirements: JDK 17, Android SDK platform and build tools for API 37. ./gradlew test testDebugUnitTest lintDebug assembleDebug assembleRelease ``` +The debug APK lands at `app/build/outputs/apk/debug/app-debug.apk`. A clean checkout has no signing material, so `assembleRelease` produces an unsigned release APK. + With an emulator or device attached: ```bash ./gradlew :core:storage:connectedDebugAndroidTest :app:connectedDebugAndroidTest ``` +The app suite separates the Android signed-configuration regression +([`AndroidConfigurationImportTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt)), +the full participant UI flow ([`CoreFlowTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt)), +and the five-collector Android integration +([`P2CollectorEmulatorTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/P2CollectorEmulatorTest.kt)). +The last test skips when gyro, light, or proximity hardware is absent. Its optional exact-value mode +expects a sensor-capable emulator that the host has already configured; the test does not fake +Android's sensor APIs: + +```bash +adb -s emulator-5554 emu power ac on +adb -s emulator-5554 emu power status charging +adb -s emulator-5554 emu power capacity 73 +adb -s emulator-5554 emu sensor set gyroscope 1.25:-2.5:0.5 +adb -s emulator-5554 emu sensor set light 123 +adb -s emulator-5554 emu sensor set proximity 1 +./gradlew :app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=cool.jacoblin.particeps.P2CollectorEmulatorTest \ + -Pandroid.testInstrumentationRunnerArguments.p2SyntheticInputs=true +``` + CI runs unit tests, Android lint, and debug and release builds on every pull request. Please check those pass locally first. Note that `allWarningsAsErrors` is on, so an unhandled branch in an exhaustive `when` is a build failure rather than a warning. ### Tests diff --git a/README.md b/README.md index 6c6e924..48869d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Particeps -**Participant-first sensing for research.** Run a mobile data collection study without building an app. A study is a signed configuration file: choose which collectors to run, set their parameters and the study duration, sign it, and hand it to participants. Data is collected on the device and encrypted as it is written; it reaches you as an encrypted export the participant sends, or on a schedule if the study names an upload endpoint. +**Participant-first sensing for research.** Run a mobile data collection study without building an app. A study is a signed configuration file: choose which collectors to run, set their parameters and the study duration, sign it, and hand it to participants. Data is collected on the device and encrypted as it is written. It reaches you as an encrypted export the participant sends, or on a schedule if the study names an upload endpoint. [![Android CI](https://github.com/JacobLinCool/particeps/actions/workflows/ci.yml/badge.svg)](https://github.com/JacobLinCool/particeps/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -12,14 +12,16 @@ Standing up a mobile sensing study normally means writing an Android app, gettin *Particeps* is Latin for one who takes part or shares in something, and it is the root of *participant*. -The name describes where the design puts the participant, and it is worth being exact about what that does and does not mean. Events are written and encrypted on the participant's own phone; every collector a study enables is shown to them, with what it records and what it cannot establish, before they are asked to consent; and nothing is collected until they press Start. Those are defaults the implementation actually provides. What the name does not grant is authorship of the study: the collector set, the duration, and whether the study uploads on a schedule are fixed in the signed configuration, and a participant cannot change them, add to them, or recall a bundle once it has been delivered. Their leverage over a running study is bounded and real — decline it outright, withhold the Android access an *optional* collector needs so that collector stays off, pause, finish early, withdraw, and delete the local data. A collector the configuration marks required is not optional in that sense: withholding its access stops the study rather than trimming it. +The name describes where the design puts the participant, and it is worth being exact about what that does and does not mean. Events are written and encrypted on the participant's own phone. Every collector a study enables is shown to them before they are asked to consent, with what it records and what it cannot establish. Nothing is collected until they press Start. Those are defaults the implementation actually provides. + +What the name does not grant is authorship of the study. The collector set, the duration, and whether the study uploads on a schedule are fixed in the signed configuration. A participant cannot change them, add to them, or recall a bundle once it has been delivered. Their leverage over a running study is bounded and real — decline it outright, withhold the Android access an *optional* collector needs so that collector stays off, pause, finish early, withdraw, and delete the local data. A collector the configuration marks required is not optional in that sense: withholding its access stops the study rather than trimming it. ## How a study works 1. **Generate your keys.** One Ed25519 pair to sign study configurations, one X25519 HPKE pair to decrypt bundles. `researcher-tools` writes raw 32-byte keys as unpadded base64url. 2. **Write the study.** A strict Protocol v1 RFC 8785 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 `.partcfg` 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 `.partcfg`, or open a `particeps://join/v1` link that names where those exact bytes are served and pins their SHA-256. 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. +3. **Sign it.** `researcher-tools sign` produces a `.partcfg` file that any build of the app can verify, with no change to the app. +4. **Distribute.** Participants install the app and import your `.partcfg`, or open a `particeps://join/v1` link that names where those exact bytes are served and pins their SHA-256. 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. Collection begins only when they press the start button. 5. **Collect.** Events are written to encrypted on-device storage. Participants can pause, resume, finish early, or withdraw. 6. **Export and analyse.** The participant exports an encrypted bundle and sends it to you. If the study declares an upload endpoint, the app also delivers immutable ciphertext bundles to an R2 receiver on a schedule. `particeps-analysis` inventories, verifies, decrypts, reassembles, and writes typed Parquet offline. @@ -61,18 +63,18 @@ source does not touch storage, the runtime, or protocol/export code. The Studies collect from people's personal phones, so the platform is built to support a defensible ethics submission and honest commitments to participants. -- **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. +- **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. They are written 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: RFC 8785 bytes, exact schema, known collectors, Android platform, validity window, and minimum client build. 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, daily-local, or signed random-local-window triggers. Random instants are selected with a CSPRNG and persisted before scheduling, so retries and reboot do not redraw them; clock/time-zone changes do not rewrite already materialized occurrences. Native surveys support short text, integer scales, single choice, and multiple choice; only a confirmed, complete submission enters the encrypted event stream. +- **Durable interventions and native surveys.** Notification actions can use one-time, recurring, daily-local, or signed random-local-window triggers. Random instants are selected with a CSPRNG and persisted before scheduling, so retries and reboot do not redraw them. Clock and time-zone changes do not rewrite already materialized occurrences. 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 in the encrypted document. Upload URLs and headers contain no participant, assigned, experiment, or configuration ID. Their bundle UUID, configuration digest, researcher key ID, exact range/count, size, and digest are untrusted routing claims, not participant authentication. - **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, interval, and metered-network policy. The endpoint host, cadence, and network condition are shown before consent. Before HTTP starts, the app durably stages one immutable ciphertext bundle in no-backup storage: about 16 MiB of plaintext and at most 32 MiB on the wire. Retries send those exact bytes with fixed length and digest. Only a matching seven-field receipt on `201 Created` or exact-replay `200 OK` advances the watermark; redirects, `202`, malformed receipts, and other terminal responses do not. Finishing or withdrawing leaves delivery running until the tail arrives. Undelivered events are never reclaimed to make room. +- **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. Before HTTP starts, the app durably stages one immutable ciphertext bundle in no-backup storage: about 16 MiB of plaintext and at most 32 MiB on the wire. Retries send those exact bytes with fixed length and digest. A bundle counts as delivered only when the receiver returns a receipt that matches the staged bundle exactly, so any other response leaves those events undelivered. [Protocol v1](protocol/v1/README.md) defines the receipt and which responses may advance the watermark. Finishing or withdrawing leaves delivery running until the 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. ### Who published the study -A configuration carries its own signing public key in a mandatory `signer` block, so one published app can verify any researcher's study without a rebuild. The cost is that a signature alone says nothing about origin: the researcher name and contact shown on the consent screen are text the signer chose. The mitigation is the key fingerprint — the first 16 bytes of SHA-256 over the signing public key, rendered as eight groups of four hex characters — which the consent step shows under the heading *Configuration signature*. Publish your fingerprint in the material that recruits participants so they can compare the two, and note that a participant reaches a study through your recruitment channel rather than an anonymous download. +One published app can verify and run any researcher's study. The cost is that a signature alone says nothing about origin: the researcher name and contact shown on the consent screen are text the signer chose. The mitigation is the signing key fingerprint, which the consent step shows under the heading *Configuration signature*. Publish your fingerprint in the material that recruits participants so they can compare the two. Note also that a participant reaches a study through your recruitment channel rather than an anonymous download. How the signing key travels inside the signed bytes, and how its fingerprint is derived, is in the [threat model](docs/threat-model.md). The shipped build pins no signer, so it accepts any correctly signed configuration and tells the participant that the publisher is unverified. An institution that wants one build to run only its own studies adds its key to `TRUSTED_SIGNING_KEYS` in `CollectorApplication` and ships that build; every other signer is then refused outright. @@ -86,44 +88,11 @@ JDK 17, and Android SDK platform and build tools for API 37. The app targets And ### Build and test -```bash -./gradlew test testDebugUnitTest lintDebug assembleDebug assembleRelease -``` - -With an emulator or device attached: - -```bash -./gradlew :core:storage:connectedDebugAndroidTest :app:connectedDebugAndroidTest -``` - -The app suite separates the Android signed-configuration regression -([`AndroidConfigurationImportTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt)), -the full participant UI flow ([`CoreFlowTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt)), -and the five-collector Android integration -([`P2CollectorEmulatorTest`](app/src/androidTest/kotlin/cool/jacoblin/particeps/P2CollectorEmulatorTest.kt)). -The last test skips when gyro, light, or proximity hardware is absent. Its optional exact-value mode -expects a sensor-capable emulator that the host has already configured; the test does not fake -Android's sensor APIs: - -```bash -adb -s emulator-5554 emu power ac on -adb -s emulator-5554 emu power status charging -adb -s emulator-5554 emu power capacity 73 -adb -s emulator-5554 emu sensor set gyroscope 1.25:-2.5:0.5 -adb -s emulator-5554 emu sensor set light 123 -adb -s emulator-5554 emu sensor set proximity 1 -./gradlew :app:connectedDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=cool.jacoblin.particeps.P2CollectorEmulatorTest \ - -Pandroid.testInstrumentationRunnerArguments.p2SyntheticInputs=true -``` - -The debug APK lands at `app/build/outputs/apk/debug/app-debug.apk`. A clean checkout has no signing material, so `assembleRelease` produces an unsigned release APK. +The build and test command block, the emulator-attached suites, and the sensor setup the collector integration test expects are in [CONTRIBUTING.md](CONTRIBUTING.md#development). ### Try it without a real study -`researcher-tools/examples` contains a demonstration study and its key pair. Those keys are public fixtures committed to this repository: anyone can sign a configuration that presents itself as the demo study, and anyone can decrypt an export encrypted to the demo HPKE key. They are fine for development and emulator testing, never for real participants. - -For that reason a **release build ships no demonstration study** — the signed envelope and its loader are in the app's `debug` source set only, so a released app runs nothing but a study a research team signed and handed out. Build the debug variant if you want to try the participant flow without a configuration of your own. +`researcher-tools/examples` contains a demonstration study and its key pair. Those keys are public fixtures committed to this repository, fine for development and emulator testing but never for real participants, and a release build ships no demonstration study at all — see [`researcher-tools/examples/README.md`](researcher-tools/examples/README.md). Build the debug variant if you want to try the participant flow without a configuration of your own. ### Researcher CLI @@ -187,7 +156,7 @@ its codec and [Web editor](web/src/routes/researcher/InterventionEditor.svelte), materialization in [`InterventionSchedulePlanner.kt`](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt). The [session](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt) -persists the occurrence before scheduling; the Android delivery/expiry workers in +persists the occurrence before scheduling. The Android delivery and expiry workers in [`AndroidStudyPlatform.kt`](app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt) and [`BootRecoveryReceiver`](app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt) reconcile the same ID after retries, reboot, clock, or time-zone changes. The adjacent planner, @@ -218,23 +187,20 @@ New collectors are the main contribution path — see [CONTRIBUTING.md](CONTRIBU Every release candidate published so far runs under a different application ID from the current build, and the release signing key has since been rotated. Android treats each as an unrelated -application, so there is no upgrade and no migration: the older build keeps running under its own -name until it is removed, and uninstalling it destroys its Keystore key and everything encrypted -under it. Export whatever is still wanted first. - -Artifacts from before the rename are unsupported. A `.adccfg`, a `.adcexp`, an `ADCCFG01` or -`ADCEXP01` container, a `research-bundle-v1` document, an `adc://join/v1` link, and an upload -carrying `application/vnd.adc.research-bundle` or an `X-ADC-*` header are all invalid input to every -current implementation. There is no converter. [CHANGELOG.md](CHANGELOG.md) says which release -carries which identity. +application, so there is no upgrade and no migration. The older build keeps running under its own +name until it is removed. Uninstalling it destroys its Keystore key and everything encrypted under +it, so export whatever is still wanted first. Artifacts from before the rename are unsupported +input to every current implementation, and there is no converter. +[CHANGELOG.md](CHANGELOG.md) says which release carries which identity, which spellings it retired, +and what each release asks of an existing install. ## Status This repository implements and tests the full local participant flow on Android 14–17. -**The app's own screens ship in English and Traditional Chinese.** The interface follows the phone's system language, and a picker in the app's header changes it for this app alone; that picker writes through Android's `LocaleManager`, so it is the same setting as the system's per-app language screen rather than a second one beside it. Adding a language is a `values-*` directory and one line in `res/xml/locales_config.xml`. +**The app's own screens ship in English and Traditional Chinese.** The interface follows the phone's system language, and a picker in the app's header changes it for this app alone. That picker writes through Android's `LocaleManager`, so it is the same setting as the system's per-app language screen rather than a second one beside it. Adding a language is a `values-*` directory and one line in `res/xml/locales_config.xml`. -Researcher-supplied text is a separate matter: the study title, purpose, researcher name, contact, and consent summary are rendered exactly as they were signed, in whatever language they were written, whatever language the app is in. Recruiting across languages therefore means one signed configuration per language. +Researcher-supplied text is a separate matter. The study title, purpose, researcher name, contact, and consent summary are rendered exactly as they were signed, in whatever language they were written, whatever language the app is in. Recruiting across languages therefore means one signed configuration per language. Running a real study also needs work this repository cannot do for you: ethics and legal approval, your own study signing key and a published fingerprint for it, a data governance plan, and validation on the physical devices and OEM builds you intend to support. Emulator tests passing is not ethics approval, Play policy compliance, or scientific validity. diff --git a/SECURITY.md b/SECURITY.md index f625eda..9097ab7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,7 +29,7 @@ Anything showing that a security property the current release claims does not ac - **Data leaving the device other than as the signed configuration specifies and the consent screen disclosed.** A study may declare an upload endpoint, and the app then posts encrypted bundles to exactly that endpoint on the stated schedule. Any transmission to another destination, on another schedule, from a study whose `upload` block is empty, or carrying anything the researcher's private key does not have to open, is in scope. - **Reading study data at rest** without the device's Keystore key — decrypting event segments or metadata, or extracting the key. - **Reading an encrypted export** without the researcher's HPKE private key, or making a tampered export decrypt successfully. -- **Accepting a study configuration that should have been rejected** — a configuration running without a valid Ed25519 signature over its exact canonical bytes, a mismatch between what is signed and what is executed, a signer key ID in the envelope that disagrees with the one in the signed bytes, an expiry or app-version check bypass, or a build with pinned signers accepting a configuration signed by anyone else or carrying a key other than the pinned one. That the shipped build accepts a correctly signed configuration from an unpinned signer is the documented design, not a vulnerability; the consent screen discloses it. +- **Accepting a study configuration that should have been rejected** — a configuration running without a valid Ed25519 signature over its exact canonical bytes, a mismatch between what is signed and what is executed, a signer key ID in the envelope that disagrees with the one in the signed bytes, or an expiry or app-version check bypass. On a build with pinned signers, accepting a configuration signed by anyone else, or one carrying a key other than the pinned one, is in scope as well. That the shipped build accepts a correctly signed configuration from an unpinned signer is the documented design, not a vulnerability. The consent screen discloses it. - **Collecting outside the participant's consent** — recording before an explicit start, after a pause boundary, after completion or withdrawal, or from a collector the configuration did not enable. - **A collector obtaining more than its contract allows** — most importantly the keyboard collector reaching text, or any collector reaching data outside its declared surface. - **Silent data loss or corruption** presented to the participant or researcher as a complete dataset. @@ -43,13 +43,13 @@ These are documented limitations rather than vulnerabilities. See [docs/threat-m - Attacks requiring a rooted or already-compromised device, or a malicious OS build. - Attacks requiring physical access to an unlocked device. -- A malicious researcher. A participant who consents to a study is trusting that research team; this software limits what a study can technically do, but a researcher can still design a study that collects more than a participant expected. +- A malicious researcher. A participant who consents to a study is trusting that research team. This software limits what a study can technically do, but a researcher can still design a study that collects more than a participant expected. - A configuration signed by an unpinned key that names a research team it did not come from. A signature proves the file is unchanged since signing, not who wrote it. The consent screen shows the signer key ID and fingerprint and says so; the mitigation is the fingerprint a research team publishes to its participants. - What happens to an export file after the participant shares it. Once it leaves the device, its safety depends on the researcher's own key handling and storage. - Loss of the researcher's HPKE private key making exports permanently undecryptable. The current design has no escrow or recovery path. - Inference risks inherent to the data itself — that keyboard touch dynamics or location traces can be identifying is a property of the data, disclosed in the consent content, not a bug. - Findings from automated scanners with no demonstrated impact. -- The example keys in `researcher-tools/examples`. They are public fixtures on purpose and are named to say so. +- The example keys in `researcher-tools/examples`. They are public fixtures on purpose and are named to say so — see [`researcher-tools/examples/README.md`](researcher-tools/examples/README.md). ## Verifying the claims yourself diff --git a/docs/data-collector-implementation-guide.md b/docs/data-collector-implementation-guide.md index 1aa297d..ef57a70 100644 --- a/docs/data-collector-implementation-guide.md +++ b/docs/data-collector-implementation-guide.md @@ -4,9 +4,8 @@ This guide describes the v1 collector API as it exists in this repository, and h a new collector without breaking the pause, privacy, and storage invariants that the rest of the system depends on. -Read [System design](system-design.md) first for the module map, and -[System design](system-design.md) for the responsibility split. This document -covers only the collector side of that boundary. The [normative Protocol v1 contract](../protocol/v1/README.md) +Read [System design](system-design.md) first for the module map and the responsibility split. +This document covers only the collector side of that boundary. The [normative Protocol v1 contract](../protocol/v1/README.md) defines the enclosing configuration and bundle. Its machine-readable schema source is the [Protocol v1 collector catalog](../protocol/v1/collector-catalog.json); the generated Kotlin projection is @@ -33,10 +32,10 @@ codec's `when` over collector IDs is the allowlist. Every type below is declared in one file: [`core/collector-api/.../CollectorContracts.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt). -The other four files in `:core:collector-api` are `SerializedCallbackCollector.kt` and -`SourceLifecycle.kt`, the shared base class and the source registration/teardown result types -covered in [section 6](#6-lifecycle); the generated `ProtocolEventContracts.kt`; and -`LatestValueRateGate.kt`, the tested rate gate that on-change collectors use. +There are four other files in `:core:collector-api`. `SerializedCallbackCollector.kt` and +`SourceLifecycle.kt` hold the shared base class and the source registration/teardown result +types, both covered in [section 6](#6-lifecycle). `ProtocolEventContracts.kt` is the generated +projection. `LatestValueRateGate.kt` is the tested rate gate that on-change collectors use. ### Plugin and instance @@ -161,17 +160,17 @@ interface EventSink { `AdmissionToken` is a marker interface with no members. The only implementation is `EventAdmissionGate.EpochToken`, which is `private` inside [`core/experiment-runtime/.../EventAdmissionGate.kt`](../core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt). -A collector can write `object : AdmissionToken {}`, but the gate's `epoch()` extension maps -any foreign implementation to `Long.MIN_VALUE`, which never equals a live epoch, so the -event is rejected. Forging a token is possible; forging an accepted token is not. +A collector can write `object : AdmissionToken {}`. The gate's `epoch()` extension maps any +foreign implementation to `Long.MIN_VALUE`, which never equals a live epoch, so the event is +rejected. Forging a token is possible; forging an accepted token is not. `latestEvent` returns the last event this collector persisted, from bounded metadata that survives process death. Polling collectors use it to resume a coverage window instead of re-querying an interval they already recorded. It exposes only this collector's own last -event, not the event history and not another collector's data. Reclaiming local space does -not take it away: these are stored in the study metadata rather than recovered by scanning the -event log, so a collector that has been quiet for a long time still finds the timestamp it -resumes from even after the segment holding that event is gone. +event, not the event history and not another collector's data. Reclaiming local space does not +take it away. These records are stored in the study metadata rather than recovered by scanning +the event log. A collector that has been quiet for a long time therefore still finds the +timestamp it resumes from, even after the segment holding that event is gone. ### Access requirements @@ -198,7 +197,7 @@ data class AccessRequirement( `AccessKind` deliberately mixes Android runtime permissions, special access grants, an input-method selection state, and hardware capabilities. They are all preconditions the participant can see and, except for hardware, revoke. `required` is not a property of the -collector; it is copied from the `required` flag the researcher set on that collector in the +collector. It is copied from the `required` flag the researcher set on that collector in the study configuration. | `required` | Missing access at preflight | Missing access at start | @@ -212,19 +211,19 @@ placeholder events. ## 3. Invariants a collector must hold These are structural rules of the current design. Breaking one does not produce a bug to fix -later; it invalidates what the participant guide and the deployed consent texts describe, which -is a coordination problem with live studies rather than a code change. If you have a reason to -change one of these, raise it as a design discussion first. +later. It invalidates what the participant guide and the deployed consent texts describe, and +that is a coordination problem with live studies rather than a code change. If you have a +reason to change one of these, raise it as a design discussion first. | A collector does not | Why | Boundary that enforces it | | --- | --- | --- | -| Write files, databases, or preferences | Every research byte must go through the encrypted store so that sequence numbers stay contiguous and export, upload, and reclaiming can all reason about one window. A side file is invisible to export, to the storage quota, and to deletion. | Feature modules depend on `:core:collector-api`, `:core:study-definition`, and optionally `:collector:sensor-common`; `:core:storage` is not on the classpath, and `CollectorContext` carries no `StudyStore`. | +| Write files, databases, or preferences | Every research byte must go through the encrypted store. That is what keeps sequence numbers contiguous, so export, upload, and reclaiming can all reason about one window. A side file is invisible to export, to the storage quota, and to deletion. | Feature modules depend on `:core:collector-api`, `:core:study-definition`, and optionally `:collector:sensor-common`; `:core:storage` is not on the classpath, and `CollectorContext` carries no `StudyStore`. | | Change study state | `IMPORTED` → … → `WITHDRAWN` is the participant's control surface. A collector that could move it could un-pause a study the participant paused. | `ExperimentStateMachine.transition` is called only from `ExperimentRuntime` in `:core:experiment-runtime`, which no collector module depends on. | | Start an `Activity` or drive UI | The app must never interrupt the participant on a collector's schedule. | Collector modules do not depend on `:app`. Plugins are constructed with `context.applicationContext`. | | Schedule interventions or notifications | Intervention timing and occurrence identity come from the signed configuration and are reconciled by the session manager. | The `StudyWorkScheduler` port is declared in `:core:study-application`; the WorkManager adapter is in `:app`. Neither is on a collector's classpath. | -| Export, encrypt, or package data | Export is a participant-initiated act over a bounded sequence window, encrypted to a researcher HPKE key. | `:core:export` and `:core:crypto` are not on any collector's classpath. | +| Export, encrypt, or package data | Export is a participant-initiated act over a bounded sequence window, encrypted to a researcher HPKE key. [Protocol v1](../protocol/v1/README.md) defines that container. | `:core:export` and `:core:crypto` are not on any collector's classpath. | | Open a socket or upload | Network transport lives in the study application layer, where the `StudyUploader` sends only a staged encrypted bundle to the signed endpoint. A collector reaching the network would bypass signed scope and consent. | `CollectorContext` exposes no network client, and forbidden network classes, imports, and dependencies fail the Collector capability check. | -| Record text, characters, or content typed on the research keyboard | The consent text tells participants the keyboard never sees what they write. Touch dynamics research does not need the characters, so the characters are never carried across the boundary. | `ResearchKeyboardView.onTouchEvent` passes `key.category.name` to `ImeObservationBridge.publish`, never `key.text`. `ImeTouchObservation` has no field that could hold a character. The committed text goes to `InputConnection` and stops there. | +| Record text, characters, or content typed on the research keyboard | The consent text tells participants the keyboard never sees what they write. Touch dynamics research does not need the characters, so the characters are never carried across the boundary. | `ResearchKeyboardView.onTouchEvent` passes `key.category.name` to `ImeObservationBridge.publish`, never `key.text`. `ImeTouchObservation` has no field that could hold a character. The committed text goes to `InputConnection` through `commitKey` and stops there. | | Log payload values, paths, package names, or exception messages | A logcat line is readable by anyone with adb access and is not covered by the encrypted store. | `CollectorHealth.reasonCode` is constrained to `[A-Z][A-Z0-9_]{2,63}`, which cannot hold free text. There is no other diagnostic channel in the API. | ### Static policy is not a sandbox @@ -258,8 +257,8 @@ POST_NOTIFICATIONS RECEIVE_BOOT_COMPLETED WAKE_LOCK ``` plus the signature-level `cool.jacoblin.particeps.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` -that AndroidX contributes. `INTERNET` belongs to the study application layer's upload worker; -its presence no longer tells you whether any given study transmits, which is why bytecode and +that AndroidX contributes. `INTERNET` belongs to the study application layer's upload worker. +Its presence no longer tells you whether any given study transmits. That is why bytecode and dependency policy are required in addition to a permission diff. ## 4. Module layout and dependency direction @@ -336,14 +335,15 @@ protected abstract suspend fun unregisterSource(): SourceTeardownResult Both return an explicit outcome rather than `Unit`, because a failure has to say whether the Android source was left attached. `SourceRegistrationResult` is `Registered`, `Released(failure)` when rollback proved nothing is attached, or `Uncertain(failure)` when it did not. -`SourceTeardownResult` is `Released` or `ReleasedWithFailure(failure)`, both of which promise the -callbacks are physically released or independently isolated; throwing instead leaves the source -uncertain, and the base class then refuses to register a second generation over it. Both types are -declared in -[`SourceLifecycle.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycle.kt), -alongside `registerSourceWithRollback`, which returns the registration result, and -`completeSourceTeardown`, which runs every teardown operation before rethrowing the first failure so -the collector can return a teardown result of its own. +`SourceTeardownResult` is `Released` or `ReleasedWithFailure(failure)`. Both promise the callbacks +are physically released or independently isolated. Throwing instead leaves the source uncertain, +and the base class then refuses to register a second generation over it. + +Both types are declared in +[`SourceLifecycle.kt`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SourceLifecycle.kt). +That file also holds `registerSourceWithRollback`, which returns the registration result, and +`completeSourceTeardown`, which runs every teardown operation before rethrowing the first failure. +The collector can then return a teardown result of its own. Use it unless your source is a periodic query. What the base class does with each call: @@ -356,8 +356,8 @@ Use it unless your source is a periodic query. What the base class does with eac a failure rather than a silent second listener. 4. On success sets `ACTIVE`. On failure it sets `FAILED` / `SOURCE_REGISTRATION_FAILED` and rethrows so the runtime can record `COLLECTOR_START_FAILED`. It drains the consumer and clears - the job only when the source is proven released, which is also the only case in which the - collector can be started again afterwards; an `Uncertain` registration leaves the consumer + the job only when the source is proven released. That is also the only case in which the + collector can be started again afterwards: an `Uncertain` registration leaves the consumer running and blocks a restart. ### `pause()` @@ -381,7 +381,7 @@ in the queue. The runtime calls `admissionGate.open()` on resume, which increments the epoch. Tokens captured before the pause are dead. A retrospective-query collector must start a new coverage -window at resume time and must not backfill the paused interval — see `network_usage.v1` and +window at resume time, and must not backfill the paused interval. See `network_usage.v1` and `usage_events.v1`, both of which reset their query start to the resume wall time. ### `stop()` @@ -459,8 +459,8 @@ it. When the platform gives you its own timestamp, record it as a payload field - `usage_events.v1` stores `UsageEvents.Event.timeStamp` as `source_time_utc_millis`. Never overwrite a source time with a write time. A batched sensor delivery can hand you -samples that were taken seconds earlier, and an analyst who cannot tell the difference will -draw a wrong conclusion about timing. +samples that were taken seconds earlier. An analyst who cannot tell the difference will draw +a wrong conclusion about timing. ### Emit results @@ -471,10 +471,10 @@ draw a wrong conclusion about timing. | `ContractViolation` | the draft is not this collector's ID, or it fails the catalog-derived event contract | set `FAILED` with a fixed reason code | | `StorageFailure` | the append failed | set `FAILED` with a fixed reason code | -`ContractViolation` is a defect in the collector, not a runtime condition: the runtime checks the +`ContractViolation` is a defect in the collector, not a runtime condition. The runtime checks the declared ID, payload schema, payload type, field set, field values, and worst-case encoded size -before it consults the admission gate, and returns without recording an incident or closing the -gate. Nothing about it improves on a retry. +before it consults the admission gate. It then returns without recording an incident or closing +the gate. Nothing about it improves on a retry. `StorageFailure` is not recoverable by retrying. On the runtime side, `emit` force-closes the admission gate, records the `STORAGE_WRITE_FAILED` incident, and launches a fail-closed @@ -563,10 +563,10 @@ whichever wrote last. Runtime-level incidents (`COMMAND_REJECTED`, `RUNTIME_FAIL ## 10. The twelve built-in collectors -Twelve is the number a study configuration can choose from. The catalog holds thirteen entries: -the thirteenth, `interventions.v1`, is marked `"selectable": false` because the runtime rather -than a collector emits those events, and a study cannot select it. It is documented in the -[data dictionary](data-dictionary.md) instead. +Twelve is the number a study configuration can choose from. The catalog holds thirteen entries. +The thirteenth, `interventions.v1`, is marked `"selectable": false`, because the runtime rather +than a collector emits those events. A study cannot select it, and it is documented in the +[data dictionary](data-dictionary.md#intervention-and-survey-events-interventionsv1) instead. Each entry states what the collector records and, as importantly, what its data cannot be used to claim. @@ -627,9 +627,10 @@ analyst's claims to make and defend. Empty exact config, no access, 64-event callback queue. A runtime-registered, non-exported receiver snapshots whole percentage, charging state/source, and power-save mode. It deliberately -does not request serial, health, temperature, current, voltage, or capacity. Exact duplicates are -suppressed and rapid changes retain the newest state under a one-minute bound through the tested -`core:collector-api/LatestValueRateGate`. +asks the platform for nothing beyond that snapshot; the +[data dictionary](data-dictionary.md#battery_statev1) lists what it does not record. Exact +duplicates are suppressed, and rapid changes retain the newest state under a one-minute bound +through the tested `core:collector-api/LatestValueRateGate`. ### `temporal_context.v1` @@ -675,8 +676,8 @@ cross-device precision or presence claim is made. ``` Uses `ConnectivityManager.registerDefaultNetworkCallback`. Every `registerSource()` — so at -both start and resume — also writes one `NETWORK_SNAPSHOT` describing the current state, so a -segment never begins with an unknown connection state. +both start and resume — also writes one `NETWORK_SNAPSHOT` describing the current state. A +segment therefore never begins with an unknown connection state. Payload types: `NETWORK_AVAILABLE`, `NETWORK_LOST`, `NETWORK_CAPABILITIES`, `NETWORK_SNAPSHOT`. @@ -690,10 +691,10 @@ fields. `NOT_ROAMING` capabilities. The bandwidth values are the platform's link estimates, not measurements. -Do not add SSID, BSSID, IP address, DNS server, URL, packet content, active socket probing, or -any network identifier without raising it as a design discussion first. Those turn a -connection-state record into something materially different, with consequences for consent text -and ethics review that go well beyond the code. +Do not widen this collector past connection shape without raising it as a design discussion +first. Such an addition turns a connection-state record into something materially different, +with consequences for consent text and ethics review that go well beyond the code. The +[data dictionary](data-dictionary.md#network_statev1) lists what this collector does not record. ### `network_usage.v1` @@ -713,8 +714,8 @@ and ethics review that go well beyond the code. ``` - `poll_interval_minutes`: 1–1,440. The floor is a minute so a pilot does not have to wait for - a result; note that a shorter window buys finer sampling of the platform's counters, not finer - truth, since `NetworkStatsManager`'s own accounting granularity is coarser than that. + a result. Note that a shorter window buys finer sampling of the platform's counters, not finer + truth: `NetworkStatsManager`'s own accounting granularity is coarser than that. - `transports`: non-empty set of `mobile` and/or `wifi`; canonical encoding sorts them by enum name and lowercases them. @@ -850,12 +851,10 @@ Three separate gates must all be open before a single touch is recorded: `IME_FLAG_NO_PERSONALIZED_LEARNING`. Typing still works in those fields; only the research capture is suppressed, and the keyboard says so on screen. -The keyboard never records key identity or text. `ResearchKeyboardView.onTouchEvent` passes -`key.category.name` into the bridge and nothing else about the key. The character travels a -separate path — `commitKey` writes it to `InputConnection` — and never enters an -`ImeTouchObservation`, which has no field capable of holding it. The consent text makes this -promise to participants; it is kept by the shape of the data type, not by discipline at the -call site. +The keyboard never records key identity or text. The consent text makes this promise to +participants, and it is kept by the shape of the data type rather than by discipline at the +call site. The code path that enforces it is named in +[section 3](#3-invariants-a-collector-must-hold). `pressure` and `size` are device-specific normalized values. They are not calibrated newtons or square millimetres and are not comparable across device models. @@ -967,8 +966,9 @@ allowlist, and catalog parity checks must all land together. - Add the collector to [`researcher-tools/examples/demo-study.json`](../researcher-tools/examples/demo-study.json) if it should be part of the demo study, then re-canonicalise and re-sign that file into - `app/src/debug/res/raw/demo_study_envelope.txt`. That envelope is a debug-only resource; the - release variant ships no demonstration study. + `app/src/debug/res/raw/demo_study_envelope.txt`. That envelope is a debug-only resource, so + the release variant ships no demonstration study — see + [Demonstration keys and study](../researcher-tools/examples/README.md). - Update the participant guide, the researcher guide's capability table, and this document. A collector whose data is not described to participants must not ship. - Add lifecycle/access-revocation and rate/size-bound tests, document power and storage estimates, @@ -992,8 +992,8 @@ allowlist, and catalog parity checks must all land together. - [ ] The catalog validates, generated Kotlin is current, schema-invalid and over-size events are rejected before append, and deterministic decoder fixtures exist. - [ ] Collector capability checks pass for source, compiled bytecode, and dependencies. -- [ ] `./gradlew test testDebugUnitTest lintDebug assembleDebug assembleRelease` passes — - the same command CI runs. +- [ ] The build and test commands in [CONTRIBUTING](../CONTRIBUTING.md#development) pass — + the same checks CI runs. - [ ] Disclosure plus power/storage estimates are complete, and relevant target-device behavior has been exercised before deployment. diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index 0c12b79..dacf68c 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -38,12 +38,11 @@ A decrypted bundle is a `particeps-research-bundle-v1` JSON document. ``` The example is expanded for readability. The authenticated bytes are RFC 8785 JCS, so member -order and number spelling are canonical. An automatic upload's exact event boundary is selected -before its complete ciphertext and manifest are durably staged; retries never regenerate it. +order and number spelling are canonical. -`configuration` is the canonical study configuration the participant consented to, reproduced verbatim. Every dataset therefore carries its own definition of what was supposed to be collected — including its `upload` block, so a dataset states whether the study it came from delivered data to an endpoint. +`configuration` is the canonical study configuration the participant consented to, reproduced verbatim. Every dataset therefore carries its own definition of what was supposed to be collected. That includes its `upload` block, so a dataset states whether the study it came from delivered data to an endpoint. -`configuration_signature` preserves the original signer key ID and raw Ed25519 signature, while `configuration_sha256` binds the exact embedded configuration to the outer `PTCEXP01` framing. The signature and digest are verified again during decryption; they identify which key issued the artifact but do not attest who held that key or which device submitted the bundle. `producer` records the producing platform and client build. +`configuration_signature` preserves the original signer key ID and raw Ed25519 signature. `configuration_sha256` is the digest of that same configuration. [Protocol v1](../protocol/v1/README.md) defines how both are carried and bound by the encrypted container. Both are verified again during decryption. They identify which key issued the artifact. They do not attest who held that key, or which device submitted the bundle — see the [threat model](threat-model.md) for what a signer does and does not establish. `producer` records the producing platform and client build. | Field | Meaning | | --- | --- | @@ -61,16 +60,16 @@ before its complete ciphertext and manifest are durably staged; retries never re The two ways data leaves the device produce the same document. What differs is the window. -- A **manual export** runs to whatever was durable when the participant pressed export. It starts at `first_sequence_number: "1"` unless the device has reclaimed a delivered prefix to free space, in which case it starts at the lowest sequence still on the phone. Successive exports from one participant therefore overlap, each containing everything the previous one did that has not since been reclaimed. -- An **uploaded chunk**, from a study whose configuration names an endpoint, starts after the last sequence an exact receipt confirmed. The app selects a boundary near a 16 MiB plaintext target, durably stages one immutable ciphertext bundle, and enforces a 32 MiB wire ceiling. Consecutive chunks normally abut; exact replays have the same bundle UUID and bytes. +- A **manual export** runs to whatever was durable when the participant pressed export. It starts at `first_sequence_number: "1"`. If the device has reclaimed a delivered prefix to free space, it starts instead at the lowest sequence still on the phone. Successive exports from one participant therefore overlap, each containing everything the previous one did that has not since been reclaimed. +- An **uploaded chunk**, from a study whose configuration names an endpoint, starts after the last sequence an exact upload receipt confirmed. Its end is a boundary the app picks near a 16 MiB plaintext target, not a boundary stated anywhere in the study's configuration. Consecutive chunks normally abut. An exact replay carries the same bundle UUID and the same bytes as the delivery it repeats. [Protocol v1](../protocol/v1/README.md) defines the receipt and what makes a replay exact. Read `first_sequence_number` and `last_sequence_number` on every bundle rather than assuming a starting point or deriving a boundary from the study's configuration. In an uploading study the complete dataset for a participant is the chunks plus the final export, joined on sequence number. -A manual bundle is bounded by `storage.maximum_local_bytes`, which is why `researcher-tools decrypt` streams rather than decrypting in memory. The 32 MiB ceiling applies to automatic upload bodies. +A manual bundle is bounded by `storage.maximum_local_bytes` rather than by the automatic-upload wire ceiling, which is why `researcher-tools decrypt` streams rather than decrypting in memory. `state`, `transitions`, and `configuration` describe the study as a whole in both cases, not just the window, so the same transition history repeats in every chunk. `events`, `event_count`, `first_sequence_number`, and `last_sequence_number` are window-scoped. -`format` is bound into the bundle's cryptographic associated data, so a reader built for a different version fails to decrypt rather than silently misreading one: the authentication tag fails before any field is parsed. +A reader built for a different bundle format fails to decrypt rather than silently misreading one: the authentication tag fails before any field is parsed. [Protocol v1](../protocol/v1/README.md) lists everything the container authenticates alongside `format`. ### The transition history @@ -114,7 +113,7 @@ A state is one of `IMPORTED`, `CONFIG_VERIFIED`, `CONSENT_PENDING`, `ACCESS_SETU The history is checked before any plaintext is published: the first `from` is `IMPORTED`, each `from` equals the previous `to`, each `reason` agrees with its destination, the pair is a legal transition, and the last `to` equals `state`. A bundle whose history does not chain fails verification rather than decoding partially. -Reconstruct the running and paused windows from `transitions` rather than from export times. `particeps-analysis` validates the history but does not materialize it: the typed Parquet dataset holds collector events only, so this array is read from the decrypted bundle JSON. +Reconstruct the running and paused windows from `transitions` rather than from export times. `particeps-analysis` validates the history but does not materialize it. The typed Parquet dataset holds collector events only, so this array is read from the decrypted bundle JSON. ### The event envelope @@ -166,7 +165,7 @@ The common event envelope does not carry a time zone or UTC offset. A study that `temporal_context.v1` receives the bounded snapshots documented below; otherwise local time cannot be reconstructed from an export. -`observed_time` is stamped inside the collector callback at capture time, with two exceptions: `network_usage.v1` and `usage_events.v1` are polling collectors and stamp one `observed_time` per poll, shared by every event in that batch. For those two, use the in-payload source time instead. +`observed_time` is stamped inside the collector callback at capture time, with two exceptions. `network_usage.v1` and `usage_events.v1` are polling collectors: they stamp one `observed_time` per poll, shared by every event in that batch. For those two, use the in-payload source time instead. Several collectors also carry a source-supplied time in their payload. **Do not subtract across clock bases:** @@ -177,7 +176,7 @@ Several collectors also carry a source-supplied time in their payload. **Do not ### Deduplication -Exports overlap by design — a participant can export repeatedly, and each export contains everything from its retained floor up to its boundary. Partition by `(experiment_id, configuration_id)` and deduplicate on `(participant_instance_id, sequence_number)`, making the complete event identity all four values. Identical repeats are duplicates; different content at one identity is a conflict, never a last-write-wins update. Do not merge solely on an assigned ID. Receiver ingestion instead deduplicates the immutable bundle UUID and exact bytes/metadata; a participant/range pair is not an ingest key. +Exports overlap by design — a participant can export repeatedly, and each export contains everything from its retained floor up to its boundary. Partition by `(experiment_id, configuration_id)` and deduplicate on `(participant_instance_id, sequence_number)`, making the complete event identity all four values. Identical repeats are duplicates; different content at one identity is a conflict, never a last-write-wins update. Do not merge solely on an assigned ID. Receiver ingestion uses a different key entirely — the immutable bundle UUID with its exact bytes and metadata, never a participant/range pair — as [Protocol v1](../protocol/v1/README.md) sets out. ### Intervention and survey events (`interventions.v1`) @@ -204,7 +203,7 @@ For compliance metrics, start with the lifecycle event that actually supports th - Across a process restart, `network_usage.v1` and `usage_events.v1` do resume their query window from the last stored event, so a restart is not the same as a pause. - A collector that loses access reports `BLOCKED_ACCESS` and stops. It never emits a placeholder or interpolated value. -A reclaimed prefix is not one of these gaps. When a bundle's `first_sequence_number` is above 1, the events below it were collected, delivered to the study's endpoint, and then removed from the phone to free space — they are in the chunks that endpoint received, not missing from the study. Events that were never delivered are never removed. +A reclaimed prefix is not one of these gaps. When a bundle's `first_sequence_number` is above 1, the events below it were collected, delivered to the study's endpoint, and then removed from the phone to free space. They are in the chunks that endpoint received, not missing from the study. Events that were never delivered are never removed. --- @@ -329,7 +328,7 @@ orientation, posture, gesture, or activity labels. Raw ambient illuminance after a monotonic rate gate and change threshold. Because Android light sensors are commonly on-change sources, the newest threshold-sized change inside the minimum -interval is retained and emitted when the interval opens; its original observation time and +interval is retained and emitted when the interval opens. Its original observation time and `source_elapsed_realtime_nanos` are preserved. A later reading equivalent to the last emitted lux value cancels that pending change. Accuracy is descriptive metadata on emitted lux samples and is not an independent emission trigger. @@ -450,7 +449,7 @@ The one-minute floor is there so a pilot can confirm within a minute that the co | `rx_packets` | long | packets | Device total received | | `tx_packets` | long | packets | Device total transmitted | -**Interpretation limits.** The query is `querySummaryForDevice` with a null subscriber ID: device totals only. There is no per-app or per-UID attribution, and none can be recovered. Android's accounting is coarse and can lag, so a window's totals describe the window, not the instant traffic occurred within it. The first event arrives one full poll interval after the study starts, and resuming from a pause restarts the window at the moment of resume — the paused interval is never counted. +**Interpretation limits.** The query is `querySummaryForDevice` with a null subscriber ID: device totals only. There is no per-app or per-UID attribution, and none can be recovered. Android's accounting is coarse and can lag, so a window's totals describe the window, not the instant traffic occurred within it. The first event arrives one full poll interval after the study starts. Resuming from a pause restarts the window at the moment of resume, so the paused interval is never counted. Not recorded: per-app attribution, subscriber ID, hostnames, URLs, destinations, content, instantaneous throughput, ethernet or VPN transports. @@ -479,7 +478,7 @@ The one-minute floor is a piloting setting. Polling more often does not make the | `source_time_utc_millis` | long | ms since epoch, UTC | Android's own event time. Use this, not `observed_time`, which is the poll time. | Always | | `package_name` | string | — | Package of the app the event concerns | **Omitted entirely** when Android reports it as null or blank | -**Interpretation limits.** These are raw events, not a session stream. Android's retention and delivery are not guaranteed to be complete or timely, and this collector does not reconstruct sessions, durations, or foreground time — if you need those, you derive them, and the gaps are yours to handle. `SCREEN_INTERACTIVE` means the screen was on and interactive; it does not mean the participant was looking at it. +**Interpretation limits.** These are raw events, not a session stream. Android's retention and delivery are not guaranteed to be complete or timely. This collector does not reconstruct sessions, durations, or foreground time. If you need those, you derive them, and the gaps are yours to handle. `SCREEN_INTERACTIVE` means the screen was on and interactive; it does not mean the participant was looking at it. Not recorded: activity or class names (package only), window titles, notification content, app usage durations, or any unmapped event type. @@ -557,13 +556,13 @@ Touch dynamics on the study's own keyboard surface. This is the only collector c | `key_category` | string | — | One of `"LETTER"`, `"SPACE"`, `"BACKSPACE"`, `"ENTER"` — **the category only, never which key** | | `geometry_version` | string | — | Constant `"qwerty-v1"`, identifying the fixed layout that makes relative coordinates interpretable | -**What is structurally impossible here.** Key identity and text never reach the event path. The keyboard's typing path and its observation path are separate, and only the key's *category* is passed to the observer — so an event can tell you a letter key was pressed, never which letter. There is no way to reconstruct typed text from this data. +**What is structurally impossible here.** Key identity and text never reach the event path. The keyboard's typing path and its observation path are separate, and only the key's *category* is passed to the observer. An event can therefore tell you a letter key was pressed, never which letter. There is no way to reconstruct typed text from this data. **Capture is disabled entirely** when the input field is a password field of any variation, when the editor sets `IME_FLAG_NO_PERSONALIZED_LEARNING`, or when no field information is available at all. The keyboard keeps working; only observation stops. This is fail-closed: an unknown field is treated as sensitive. Only `MOVE` events are rate-limited, at the configured sampling rate. `DOWN`, `UP`, and `CANCEL` are never dropped. Touches that land outside a key, and secondary pointers in a multi-touch gesture, produce no events. -**This is still identifying.** No text does not mean no risk. Typing rhythm, dwell times, and within-key touch position are behaviourally distinctive and can support inference about the person and, in aggregate, about what kind of input they were producing. It must be disclosed explicitly in consent content, and participants should be told they can switch back to their normal keyboard before sensitive input. +**This is still identifying.** No text does not mean no risk. Typing rhythm, dwell times, and within-key touch position are behaviourally distinctive. They can support inference about the person, and in aggregate about what kind of input they were producing. It must be disclosed explicitly in consent content, and participants should be told they can switch back to their normal keyboard before sensitive input. Not recorded: characters, committed text, surrounding text, clipboard, suggestions or autocorrect data, absolute screen coordinates, keyboard pixel dimensions, the target app's package, `EditorInfo` contents, or calibrated force. @@ -571,20 +570,20 @@ Not recorded: characters, committed text, surrounding text, clipboard, suggestio ## Volume and quota -A study declares a local storage quota between 8 MiB and 8 GiB (8,388,608 to 8,589,934,592 bytes). Events are written into 4 MiB segments, at most 2,048 of them resident at once, which is what lets a study reach the top of that range while still reclaiming space 4 MiB at a time. A single encoded event may not exceed 64 KiB. A segment is appended to and never rewritten. +A study declares a local storage quota between 8 MiB and 8 GiB (8,388,608 to 8,589,934,592 bytes). Events are written into 4 MiB segments, at most 2,048 of them resident at once. That is what lets a study reach the top of that range while still reclaiming space 4 MiB at a time. A single encoded event may not exceed 64 KiB. A segment is appended to and never rewritten. -The ceiling is high because high-rate collectors fill space quickly — an accelerometer at 100 Hz produces tens of megabytes per hour — but a quota is space claimed on someone's personal phone, so ask for what the study needs rather than for the maximum. +The ceiling is high because high-rate collectors fill space quickly: an accelerometer at 100 Hz produces tens of megabytes per hour. But a quota is space claimed on someone's personal phone. Ask for what the study needs rather than for the maximum. When the quota is exhausted, the write fails and the study **fail-closes to `PAUSED`** with a storage-failure reason. It does not drop events silently and it does not overwrite the oldest data. Size your quota against your collectors' event rate before deployment, and check the accelerometer in particular: at 200 Hz it will exhaust a small quota quickly. -In a study that uploads, a confirmed delivery lets the device reclaim space. Above 80% of the quota, whole leading segments are released — down to 60% — provided every event in them was confirmed by the endpoint and they are not the segment still being written. Nothing undelivered is ever released, so an endpoint that stops answering brings a study back to the fail-closed case above rather than to a device that discards data. `StudyMetadata.retainedFromSequence` records the lowest sequence still present, sequence numbers are never reissued, and the participant's dashboard states how many earlier events were delivered and removed. +In a study that uploads, a confirmed delivery lets the device reclaim space. Above 80% of the quota, whole leading segments are released, down to 60%. A segment is released only when every event in it was confirmed by the endpoint and it is not the segment still being written. Nothing undelivered is ever released. An endpoint that stops answering therefore brings a study back to the fail-closed case above, rather than to a device that discards data. `StudyMetadata.retainedFromSequence` records the lowest sequence still present, sequence numbers are never reissued, and the participant's dashboard states how many earlier events were delivered and removed. -Study metadata is held separately from the events. It is capped at 1 MiB and kept outside the event budget by a 2 MiB reserve, so the record of what a study is and how far it has been delivered cannot be crowded out by the events it describes. Its container header is `PTCMET01`. +Study metadata is held separately from the events. It is capped at 1 MiB and kept outside the event budget by a 2 MiB reserve. The record of what a study is, and how far it has been delivered, therefore cannot be crowded out by the events it describes. Its container header is `PTCMET01`. -Normal study opening does not decrypt its event log. Framing and sequence contiguity are checked from plaintext frame headers, and each collector's most recent event is persisted in metadata rather than recovered by scanning, so start-up cost is linear in frames rather than in bytes decrypted. The sole exception is a one-boundary-ahead append journal with a durable tail: recovery authenticates exactly that tail before applying its metadata. All other event payloads are authenticated when read, so damage outside the recovery tail surfaces at export or upload rather than at launch. +Normal study opening does not decrypt its event log. Framing and sequence contiguity are checked from plaintext frame headers, and each collector's most recent event is persisted in metadata rather than recovered by scanning. Start-up cost is therefore linear in frames rather than in bytes decrypted. The sole exception is a one-boundary-ahead append journal with a durable tail: recovery authenticates exactly that tail before applying its metadata. All other event payloads are authenticated when read, so damage outside the recovery tail surfaces at export or upload rather than at launch. Each collector descriptor declares `maximumEncodedEventBytes`. The runtime encodes every admitted event with the worst-case sequence width and rejects it before append when it exceeds that -collector-specific ceiling; the store's 64 KiB global cap remains a second boundary. CI also +collector-specific ceiling. The store's 64 KiB global cap remains a second boundary. CI also checks collector source, compiled constants, and module dependencies as documented in the [Collector capability policy](../assurance/README.md). diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index a4a6ec4..8093c00 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -4,7 +4,7 @@ For maintainers of this repository. Participants and researchers do not need thi ## Workflows -All workflows live in [`.github/workflows`](../../.github/workflows). The two that decide what ships are below; `Pages` has its own section, and `Analysis CI` and `Receiver CI` verify their own directories on the pull requests that touch them. +All workflows live in [`.github/workflows`](../../.github/workflows). The two that decide what ships are below, and `Pages` has its own section. `Analysis CI` and `Receiver CI` verify their own directories on the pull requests that touch them. **`Android CI`** (`ci.yml`) runs on pushes to `main`, on pull requests, and on manual dispatch. It runs unit tests, Android lint, Protocol/catalog conformance, Collector capability checks, and debug @@ -12,21 +12,21 @@ and release builds. Successful runs retain the debug APK as an artifact for 14 d **`Android Release`** (`release.yml`) accepts only `v` tags that are reachable from `main` — for example `v0.1.0`. A tag with a prerelease suffix produces a GitHub prerelease. -The release workflow reconstructs the same `.signing` configuration used locally, re-runs tests and lint, writes the tag into `versionName` and the workflow run number into `versionCode`, then verifies the APK that Gradle signed with `apksigner verify`. The GitHub release carries both the APK and its SHA-256 checksum. Any test, signing, or verification failure stops the release; nothing is published. +The release workflow reconstructs the same `.signing` configuration used locally and re-runs tests and lint. It writes the tag into `versionName` and the workflow run number into `versionCode`, then verifies the APK that Gradle signed with `apksigner verify`. The GitHub release carries both the APK and its SHA-256 checksum. Any test, signing, or verification failure stops the release; nothing is published. ## The published site **`Pages`** (`pages.yml`) deploys the web authoring surface on pushes to `main` that touch it, not on a tag. It builds a project site, and `BASE_PATH` comes from the repository name at build time, so the published path follows whatever the repository is called. No path is pinned in the source, which is why renaming the repository is enough on its own — and why it was not optional. The repository has been renamed from `android-data-collector` to `particeps`, and the site publishes at `https://jacoblincool.github.io/particeps/`; the old path serves nothing. -`BASE_PATH` is read at build time from the event payload, and that payload carries the repository name as it was when the event was created. A run queued before a rename therefore keeps building the old path however many times it is retried, because re-running replays the same event and redeploys the same artifact. +`BASE_PATH` is read at build time from the event payload, and that payload carries the repository name as it was when the event was created. A run queued before a rename therefore keeps building the old path however many times it is retried. Re-running replays the same event and redeploys the same artifact. -The Particeps rename hit this. The deploy that followed the merge timed out — for an unrelated reason, a GitHub Pages deployment-lag incident that day — and re-running only the failed job republished a build whose HTML still pointed at `/android-data-collector/_app/…`. The title and the routes were right and the workflow was green; every asset was a 404. That is the failure mode least likely to be noticed, and the retry is what caused it, not the timeout. +The Particeps rename hit this. The deploy that followed the merge timed out, for an unrelated reason: a GitHub Pages deployment-lag incident that day. Re-running only the failed job then republished a build whose HTML still pointed at `/android-data-collector/_app/…`. The title and the routes were right and the workflow was green; every asset was a 404. That is the failure mode least likely to be noticed, and the retry is what caused it, not the timeout. -So after renaming the repository, trigger Pages fresh rather than re-running anything: `gh workflow run pages.yml --ref main`. A `workflow_dispatch` event is created at dispatch time and carries the current name. Then check the published HTML rather than the workflow's green tick — `curl -s | grep -c ''` must be zero, and one asset URL taken from that HTML must return 200. A deploy can also simply be slow or stuck on GitHub's side; check before assuming the rename broke something. +So after renaming the repository, trigger Pages fresh rather than re-running anything: `gh workflow run pages.yml --ref main`. A `workflow_dispatch` event is created at dispatch time and carries the current name. Then check the published HTML rather than the workflow's green tick. `curl -s | grep -c ''` must be zero, and one asset URL taken from that HTML must return 200. A deploy can also simply be slow or stuck on GitHub's side; check before assuming the rename broke something. GitHub will redirect the old repository URL to the new one, but only until some other repository claims the old name. Treat that as a courtesy to stale links rather than an address the project still publishes. -What a redirect cannot fix is anything already in a participant's hands. A join link is a `particeps://join/v1` URI carrying the hosting URL of the signed `.partcfg`, its SHA-256, and the signer fingerprint, and a QR code is that URI rendered; both are immutable by design. An issued link or a printed QR cannot be repointed at a new address. So if a study's artifact was served from the old Pages path, or if a poster, an email, or a consent appendix sent participants there, reissue the join link and the QR at the new URL and redistribute them. The configuration itself does not need re-signing — the envelope bytes and their digest are unchanged, only the URL that serves them. +What a redirect cannot fix is anything already in a participant's hands. A join link is a `particeps://join/v1` URI carrying the hosting URL of the signed `.partcfg`, its SHA-256, and the signer fingerprint, and a QR code is that URI rendered. Both are immutable by design, so an issued link or a printed QR cannot be repointed at a new address. If a study's artifact was served from the old Pages path, reissue the join link and the QR at the new URL and redistribute them. The same applies if a poster, an email, or a consent appendix sent participants there. The configuration itself does not need re-signing — the envelope bytes and their digest are unchanged, only the URL that serves them. ## Signing keys @@ -41,10 +41,10 @@ Losing the Android signing private key means no future build can update a direct ### No release so far can be updated in place -[CHANGELOG.md](../../CHANGELOG.md) lists which release carries which application ID. Two independent -things put every one of them out of reach of the current build: the application ID moved twice, and -a device identifies an installed application by that value; and the release signing key was rotated, -which changes the certificate a device compares on update. +No release published so far can be updated in place. The application ID moved twice and the release +signing key was rotated, and a device accepts an update only when both are unchanged. +[CHANGELOG.md](../../CHANGELOG.md) records which release carries which application ID, and states +that consequence once for every reader. The key was rotated to correct the certificate's subject, which named the pre-rename product. A certificate is signed over its own subject, so changing it means issuing a new one. That was @@ -53,12 +53,12 @@ Developer Verification had not yet been registered. It stops being affordable th participant is running a released build, so it does not happen again. Say this in the release notes. A tester expecting an in-place update reads a correct install as a -failed one, and the notes for `v1.0.0-rc.4` do not say it, so it still has to reach testers another +failed one. The notes for `v1.0.0-rc.4` do not say it, so it still has to reach testers another way. ## Android Developer Verification -Google's Developer Verification binds a verified developer identity to the package names that developer distributes and the certificates those packages are signed with. Registration is per package name, and no name this project has used was ever registered, so `cool.jacoblin.particeps` needs its own entry from scratch. Register it against the fingerprint of the **current** keystore — the rotation above means any fingerprint recorded before it is wrong. +Google's Developer Verification binds a verified developer identity to the package names that developer distributes and the certificates those packages are signed with. Registration is per package name, and no name this project has used was ever registered. `cool.jacoblin.particeps` therefore needs its own entry from scratch. Register it against the fingerprint of the **current** keystore — the rotation above means any fingerprint recorded before it is wrong. ```bash apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk @@ -96,7 +96,7 @@ git tag -a v0.1.0 -m "v0.1.0" git push origin v0.1.0 ``` -Before tagging, update `version` and `date-released` in [`CITATION.cff`](../../CITATION.cff). Both fields are present again and name `1.0.0-rc.4`, the first post-rename tag. They were absent before it because every tag up to `v1.0.0-rc.3` carries the old identity, and the file named no version rather than attributing one of those releases to Particeps. Keep both fields in step with the tag at every release. +Before tagging, update `version` and `date-released` in [`CITATION.cff`](../../CITATION.cff). Both fields are present again and name `1.0.0-rc.4`, the first post-rename tag. They were absent before it because every tag up to `v1.0.0-rc.3` carries the old identity. The file named no version rather than attributing one of those releases to Particeps. Keep both fields in step with the tag at every release. ### One-off: the Particeps cutover @@ -108,13 +108,13 @@ The rename is not a recurring step, and it is not finished when the code lands e What remains: -- Register `cool.jacoblin.particeps` under Developer Verification against the current keystore fingerprint, as above. No package name this project has used was ever registered. +- Register `cool.jacoblin.particeps` under Developer Verification against the current keystore fingerprint, as above. - The release notes for `v1.0.0-rc.4` are the generated changelog and do not say that it is a fresh install rather than an update. Say it to testers by some other route, and in the notes of the next tag. - Reissue any join link or QR that pointed at the old Pages path, as the section above describes. This is per study rather than a single step: it is finished only when no issued link and no printed QR still points there. ## Pinned signers -A study configuration carries its own Ed25519 signing public key, so issuing a study needs no release. `CollectorApplication.TRUSTED_SIGNING_KEYS` is empty in the shipped build, and a release should keep it that way: the published app then verifies any correctly signed configuration and tells the participant that the publisher is unverified. +A study configuration carries its own signing key, so issuing a study needs no release; the [threat model](../../docs/threat-model.md) covers how that trust works. `CollectorApplication.TRUSTED_SIGNING_KEYS` is empty in the shipped build, and a release should keep it that way: the published app then verifies any correctly signed configuration and tells the participant that the publisher is unverified. Populating that map is for an institution building its own APK to run only its own studies. It is strictly exclusive — every signer not listed is refused — so it is not a hardening step to apply to a general release. It is also a source change, and therefore a new build and a new release, with no revocation path short of another one. diff --git a/docs/participant-guide.md b/docs/participant-guide.md index 08800f9..3169fa1 100644 --- a/docs/participant-guide.md +++ b/docs/participant-guide.md @@ -4,7 +4,7 @@ Particeps is a research data collection app for Android. It collects and stores The name is a Latin word. It means someone who takes part. That is meant concretely here: your data stays on the phone, every source a study uses is shown to you before you are asked to consent, and nothing at all is collected until you press Start study. -It does not mean the study is yours to design. Which sources it may use, how long it runs, and whether it sends data to the research team automatically are fixed in the signed file you import, and nothing you do in the app changes them. You can read all of that before you agree, and you can say no to the whole study. Within a study, the only sources you can hold back are the ones it marks optional: declining the Android access an optional source needs, or not enabling the research keyboard, leaves that source off and the study runs without it. Section 4 covers what each source asks for and section 5 covers the keyboard; a source the study marks required stops the study instead of running without it. Data that has already reached the research team cannot be taken back. +It does not mean the study is yours to design. Which sources it may use, how long it runs, and whether it sends data to the research team automatically are fixed in the signed file you import. Nothing you do in the app changes them. You can read all of that before you agree, and you can say no to the whole study. Within a study, the only sources you can hold back are the ones it marks optional. Declining the Android access an optional source needs, or not enabling the research keyboard, leaves that source off, and the study runs without it. Section 4 covers what each source asks for and section 5 covers the keyboard. A source the study marks required stops the study instead of running without it. Data that has already reached the research team cannot be taken back. What you can always do is decline, pause, finish early, withdraw, and — once you have finished or withdrawn — permanently delete the study data on your phone. The app never asks why. @@ -16,13 +16,13 @@ Declining is a complete answer. You do not need a reason, and you do not need to The app ships in **English and Traditional Chinese**, and English is the default. On first run it follows your phone's system language: a phone set to Traditional Chinese shows the app in Traditional Chinese, and anything else gets English. -You can change it at any time, before or after you import a study. At the top right of the header there is a **globe** — a circle with a horizontal line across it and a curved meridian from top to bottom. Tap it and a picker opens, headed Language (語言). It lists **System default** (跟隨系統) first, then each language this build ships, each one written in its own language, so you can find yours without being able to read the language currently on screen. A check mark sits beside the one in use. Tapping a language applies it immediately and closes the picker; Cancel (取消) closes it without changing anything. +You can change it at any time, before or after you import a study. At the top right of the header there is a **globe** — a circle with a horizontal line across it and a curved meridian from top to bottom. Tap it and a picker opens, headed Language (語言). It lists **System default** (跟隨系統) first, then each language this build ships. Each one is written in its own language, so you can find yours without being able to read the language currently on screen. A check mark sits beside the one in use. Tapping a language applies it immediately and closes the picker; Cancel (取消) closes it without changing anything. That picker is not a private setting inside the app. It writes Android's own per-app language setting, the same one under Android Settings → Apps → Particeps → Language, so changing it in either place changes both. System default (跟隨系統) hands the choice back to your phone. Because English is the default, this guide quotes the screen in English and gives the Traditional Chinese in parentheses where you might be running it: Start study (開始研究). -**Ordinary study prose is never translated.** The study title, purpose, contact details, and consent text are shown exactly as signed. Survey titles, descriptions, questions, and choices are different: the signed configuration can carry an English and Traditional Chinese version, and the survey uses the best exact language match with the signed default as its fallback. +**Ordinary study prose is never translated.** The study title, purpose, contact details, and consent text are shown exactly as signed. Survey titles, descriptions, questions, and choices are different. The signed configuration can carry an English and a Traditional Chinese version. The survey uses the best exact language match, and falls back to the signed default. Two pieces of text stay in English whichever language you pick, because they are written into the code rather than into the translated set: @@ -36,13 +36,13 @@ Every word of the interface lives in [`app/src/main/res/values/strings.xml`](../ ## What matters most - Importing a study configuration collects nothing. Collection starts only after you consent, complete the access setup, and press Start study (開始研究). -- **Before you are asked to consent, the app shows you every source the study switched on**, one row each, with a sentence describing what it records — written by the app, with that study's actual settings filled in, not by the research team. Section 3 is that list. -- **The consent screen shows you a fingerprint of the key the study was signed with, and asks you to check it.** For most studies the app cannot tell who published the study — that is ordinary, not a fault — so the fingerprint is what ties a study to a real research team. Section 2 shows the exact screen. +- **Before you are asked to consent, the app shows you every source the study switched on**, one row each, with a sentence describing what it records. The app writes that sentence, not the research team. Section 3 is that list. +- **The consent screen shows you a fingerprint of the key the study was signed with, and asks you to check it.** For most studies the app cannot tell who published the study. That is ordinary, not a fault, and the fingerprint is what ties a study to a real research team. Section 2 shows the exact screen. - Data is encrypted and kept on your phone. It stays there unless you export and send it yourself, or the study you imported says it sends data automatically. - **Whether a study sends data automatically is shown to you before you consent.** If it does, the consent screen carries a block naming where it sends to, how often, and which networks it may use. If there is no such block, the study does not send anything. Section 2 shows you the exact screen. - A study that sends automatically sends the same encrypted package you would export by hand. Only the research team's own key can open it — not the company that runs the network, not whoever runs the receiving computer. - Automatic sending is part of the study, not a separate setting you can switch off on its own. Pausing, finishing and withdrawing all stop collection, but data already collected is still sent afterwards. Deleting the local data is what stops that. Section 7 sets out exactly what each one does. -- In a study that sends data automatically, your phone keeps its own copy too, so you can still export it yourself. If the space the study is allowed runs low, the phone may remove events the research team has already received — never anything still waiting to be sent — and the app tells you when that has happened. Section 6 explains it. +- In a study that sends data automatically, your phone keeps its own copy too, so you can still export it yourself. If the space the study is allowed runs low, the phone may remove events the research team has already received. It never removes anything still waiting to be sent, and the app tells you when a removal has happened. Section 6 explains it. - You can pause, resume, finish early, or withdraw. You never have to explain the reason to the app. - You can export your data while the study is Collecting (收集中), Paused (已暫停), Completed (已完成), or Withdrawn (已退出), and you can export as many times as you like. - Exporting does not change the study's state and does not mean the research team has received anything. You choose whether and how to send the file. @@ -56,7 +56,7 @@ A study configuration file usually ends in `.partcfg`. When you import one, the That signature check tells you the file has not been altered since it was signed. It does not, on its own, tell you who wrote it — the next section explains what the app shows you instead, and what you can do about it. -**A released app has no demo study.** The only thing it can run is a configuration a research team signed and gave you. If you see a Load demo study (載入展示研究) button under Choose a study file (選擇設定檔), you are running a development build, not a release: that study's signing key and data-encryption key are public test fixtures committed to [`researcher-tools/examples`](../researcher-tools/examples), so anyone with the repository can decrypt what it collects. Do not use it for anything real about yourself. +**A released app has no demo study.** The only thing it can run is a configuration a research team signed and gave you. If you see a Load demo study (載入展示研究) button under Choose a study file (選擇設定檔), you are running a development build, not a release. That study's keys are public, so anyone can read what it collects — see [demonstration keys and study](../researcher-tools/examples/README.md). Do not use it for anything real about yourself. ## 2. Import and read @@ -64,7 +64,7 @@ The setup is five steps, and the screen shows **one** of them at a time. The hea 1. Open the app. Before any study is imported the header shows the app's own name, Particeps, and the panel below has one button: Choose a study file (選擇設定檔). 2. Pick the `.partcfg` file your research team gave you, using Android's file picker. The header title changes to the study's own title. -3. **Study.** The panel shows the study's purpose in the research team's own words, then three rows, each an icon and a value with no label: a head and shoulders for the research team's name, an envelope for their contact details, a clock for how long the study runs. Read all four before you go on. Press Continue (繼續). +3. **Study.** The panel shows the study's purpose in the research team's own words, then three rows, each an icon and a value with no label. A head and shoulders is the research team's name, an envelope is their contact details, and a clock is how long the study runs. Read all four before you go on. Press Continue (繼續). 4. **Data.** Every source this study switched on, one row each, with a sentence saying what it records and a second line saying what it does not. Section 3 goes through them. Press Continue (繼續). 5. **Consent.** The research team's consent text, then two blocks the app writes itself: who signed the study, and whether it sends data automatically. Read all of it: the data, the purpose, the risks, the export, the withdrawal, and the deletion terms. Only if you understand it and want to take part, tick "I have read and agree to the data collection and export described above." (我已閱讀並同意上述資料收集與匯出方式。) and press Agree (同意). 6. **Access.** Grant what the study needs. Section 4 goes through it. Press Done (完成). @@ -74,13 +74,13 @@ At every one of these steps you can stop. Closing the app after importing leaves If the content does not match what the research team told you, if the contact details do not work, or if it asks for more data than you expected, stop and ask. Do not grant access first and sort it out later. -Two of these presses are not the same kind of thing. Continue (繼續) on the **Study** step is what moves the study forward internally, from imported through verified to awaiting consent. Continue (繼續) on the **Data** step only turns the page: the study's state does not change, because reading the list of sources and agreeing to it are one decision as far as the app is concerned, shown to you as two pages. One consequence is worth knowing: if you leave the app while you are on the Consent page and come back, you land on the Data page again rather than on the checkbox. +Two of these presses are not the same kind of thing. Continue (繼續) on the **Study** step is what moves the study forward internally, from imported through verified to awaiting consent. Continue (繼續) on the **Data** step only turns the page, and the study's state does not change. Reading the list of sources and agreeing to it are one decision as far as the app is concerned, shown to you as two pages. One consequence is worth knowing: if you leave the app while you are on the Consent page and come back, you land on the Data page again rather than on the checkbox. ### If you were given a link or a QR code Step 2 has a second form. Instead of sending you a file, a research team can recruit you with a link beginning `particeps://join/v1`, or with a QR code that holds one. Opening the link, or scanning the QR code and opening what it offers, starts the app, and the app fetches the study file itself from the address written inside the link. -The link names the file it expects: the address to fetch it from, the exact contents of the file, and the fingerprint of the key it is signed with. The app fetches that address once, over an encrypted connection, and checks that what arrived is byte for byte the file the link names and is signed by the key the link names. If either check fails, nothing is imported. If both pass, the file goes through the same checks section 1 describes, and the setup carries on from the **Study** step exactly as it does for a file you picked yourself. Nothing is collected until you press Start study (開始研究). +The link names the file it expects: the address to fetch it from, the exact contents of the file, and the fingerprint of the key it is signed with. The app fetches that address once, over an encrypted connection. It then checks that what arrived is byte for byte the file the link names, and that it is signed by the key the link names. If either check fails, nothing is imported. If both pass, the file goes through the same checks section 1 describes, and the setup carries on from the **Study** step exactly as it does for a file you picked yourself. Nothing is collected until you press Start study (開始研究). The app fetches the file once and never goes back for another. A link cannot change a study you have already imported, and a research team that needs to change anything has to sign a new file and give you a new link for it. @@ -90,7 +90,7 @@ Arriving this way also changes what checking the signer's fingerprint can prove. ### Where you are in the setup -Under the study title, during setup, the app draws five dots left to right, joined by a line: Study, Data, Consent, Access, Start. A step you have finished is a filled check mark, the step you are on is a thick ring, and steps still ahead are faint thin rings. The line between them fills in as you go. The names are not printed — the dots give the position and the panel below gives the content — but a screen reader announces the name of the step you are on. Nothing on this row is a button; it tells you how much of the setup is left. +Under the study title, during setup, the app draws five dots left to right, joined by a line: Study, Data, Consent, Access, Start. A step you have finished is a filled check mark, the step you are on is a thick ring, and steps still ahead are faint thin rings. The line between them fills in as you go. The names are not printed, because the dots give the position and the panel below gives the content. A screen reader does announce the name of the step you are on. Nothing on this row is a button; it tells you how much of the setup is left. Once setup is over, the dots are gone for good and that same place shows the study's status instead. Section 6 describes it. @@ -114,9 +114,9 @@ Most studies show an instruction, then the reason for it in smaller grey type: **Nothing here is in red, and that is deliberate.** A study whose signer the app does not recognise is the ordinary case, not a fault — so the block reads as something for you to do, not as an alarm. Red in this app is reserved for a source that has actually stopped working. If the fingerprints do not match, or your research team never published one, that is when to stop and ask. -**Seeing this is normal.** It is what the app shows for any study whose signer is not built into the app, which is most of them. It is not a warning that this particular study is fake. What it means is that the app cannot do this check for you, so you do it: your research team should have given you the fingerprint in the study information sheet, the consent document, or wherever they recruited you. Compare the two. If they match, the file came from whoever holds that key. If they do not match, or you were never given a fingerprint, stop and ask your research team before consenting. +**Seeing this is normal.** It is what the app shows for any study whose signer is not built into the app, which is most of them. It is not a warning that this particular study is fake. What it means is that the app cannot do this check for you, so you do it. Your research team should have given you the fingerprint in the study information sheet, the consent document, or wherever they recruited you. Compare the two. If they match, the file came from whoever holds that key. If they do not match, or you were never given a fingerprint, stop and ask your research team before consenting. -**If you arrived by a link or a QR code, the fingerprint you were sent with it is not the one to compare against.** A `particeps://join/v1` link carries a signer fingerprint inside it, and the app refuses to import a file signed by any other key, so the fingerprint on this screen is the fingerprint that was in the link. Comparing the two shows only that the link agrees with itself: whoever composed the link chose both. To learn anything, the fingerprint you compare against has to reach you by a route the link did not — the study information sheet, the consent document, your research team's published page, or the team itself through details you already had. +**If you arrived by a link or a QR code, the fingerprint you were sent with it is not the one to compare against.** A `particeps://join/v1` link carries a signer fingerprint inside it, and the app refuses to import a file signed by any other key. The fingerprint on this screen is therefore the fingerprint that was in the link. Comparing the two shows only that the link agrees with itself: whoever composed the link chose both. To learn anything, the fingerprint you compare against has to reach you by a route the link did not — the study information sheet, the consent document, your research team's published page, or the team itself through details you already had. The other possibility is a version of the app built by an institution to run only its own studies. It shows: @@ -126,7 +126,7 @@ The other possibility is a version of the app built by an institution to run onl In that case the check has already been done for you, at the time the app was built. -Nothing in the researcher name, contact details, or study description proves who wrote them — they are part of the file, so whoever signed the file chose them. The fingerprint is the part you can check independently. +Nothing in the researcher name, contact details, or study description proves who wrote them — they are part of the file, so whoever signed the file chose them. The fingerprint is the part you can check independently. How the signing key travels inside the file, and how the fingerprint is derived from it, are in the [threat model](threat-model.md). ### Your study codes @@ -153,11 +153,11 @@ A study that does send data shows a block headed This study sends data automatic | A randomly generated code travels with the data so the team can tell participants apart. It contains no name and no account. | A random code is attached so the team can tell participants apart. It contains no name and no account | | Automatic sending is part of this study and cannot be switched off on its own. Pausing or withdrawing stops new collection, but data already collected and not yet sent still goes to the research team. | Exactly that, and section 7 sets out what each control does | -The app builds this block from the signed configuration itself, not from the research team's written summary, so it describes what the app will actually do even if the summary leaves it out. If it says something the research team did not tell you, that is a reason to stop and ask before consenting. +The app builds this block from the signed configuration, not from the research team's written summary. It therefore describes what the app will actually do, even if the summary leaves it out. If it says something the research team did not tell you, that is a reason to stop and ask before consenting. Sending happens in the background. It does not need you to do anything, and your phone keeps its own copy, so you can still export by hand at any time. If the space the study is allowed runs low, the phone frees some by removing events the research team has already received; section 6 describes exactly what that looks like on screen. -One detail the block states briefly and section 7 explains in full: pausing, finishing and withdrawing all stop *collection*, but data already collected and not yet sent still goes to the research team afterwards. The only thing that stops that is deleting the local study data, which is offered once the study has finished or you have withdrawn. +One detail the block states briefly and section 7 explains in full. Pausing, finishing and withdrawing all stop *collection*, but data already collected and not yet sent still goes to the research team afterwards. The only thing that stops that is deleting the local study data, which is offered once the study has finished or you have withdrawn. ## 3. What a study can collect @@ -165,9 +165,9 @@ The app starts only the collectors listed in that study's signed configuration. The **Data** step, step 2 of the setup, lists every one of them before you are asked to agree to anything. Each row is an icon, the source's name, sometimes the word Optional (選用), and one sentence about what it records. -That sentence comes from the app, not from the research team. It is a template with that study's own settings filled into it, so a study that samples your location every ten seconds and one that samples it every ten minutes do not read the same. No field in a configuration can change the wording. +That sentence comes from the app, not from the research team. It is a template with that study's own settings filled into it, so two studies that sample at different rates do not read the same. No field in a configuration can change the wording. The [threat model](threat-model.md) explains why that is a protection and how far it reaches. -The screen also names selected limits the implementation can guarantee, such as omitted battery identity or inference labels. Those short statements are not an exhaustive threat model; use the table below and the [data dictionary](data-dictionary.md) for the complete field definitions and interpretation limits, which are written against the same source the app runs. +The screen also names selected limits the implementation can guarantee, such as omitted battery identity or inference labels. Those short statements are not an exhaustive threat model. | Row | The sentence it shows | | --- | --- | @@ -186,25 +186,25 @@ The screen also names selected limits the implementation can guarantee, such as The values written into those sentences: -- **N** is how many readings per second the study asked for. If it asked for one, the English sentence reads "about once a second or more". **"or more" is not hedging.** Android treats a study's sampling rate as a request, not a limit, and a phone is free to deliver faster — several times faster on some devices — so the rate on screen is a floor, not a ceiling. +- **N** is how many readings per second the study asked for. If it asked for one, the English sentence reads "about once a second or more". **"or more" is not hedging.** Android treats a study's sampling rate as a request, not a limit, and a phone is free to deliver faster — several times faster on some devices. The rate on screen is a floor, not a ceiling. - **T** is an interval, written in the largest unit that stays exact: 30 s (30 秒), 15 min (15 分鐘), 2 h (2 小時), 1 day (1 天). - **D** is a distance in whole metres: 25 m (25 公尺). Optional (選用) beside a name means the study can start without the Android access that source needs. If you leave that access off, that source alone reports `ACCESS_UNAVAILABLE` on the collecting screen and every other source carries on. Leaving one off is a normal choice, not an error. A source without that word needs its access granted before the study can start at all. -The sentences are short because they are the summary. This is what each source actually puts in the file: +The sentences are short because they are the summary. This is what each source actually puts in the file. The [data dictionary](data-dictionary.md) holds the exact field list for every source, including everything each one does not record; it is written against the same source code the app runs. | Source | What can be stored | Important limits | | --- | --- | --- | | App activity | Lifecycle timings of this app's own screens | Not your use of any other app | | Motion | Raw x/y/z acceleration from your phone, with timing and an accuracy status | Can be used to study movement or posture, but the app does not label it for you | -| Battery state | Whole percentage, charging state/source, and power-save mode | No health, temperature, capacity, serial, or hardware ID | +| Battery state | Whole percentage, charging state/source, and power-save mode | See the [data dictionary](data-dictionary.md) for what is left out | | Time context | Time-zone ID, UTC offset, DST state, and clock-change reason | A time-zone setting is not physical location or travel evidence | -| Rotation | Raw x/y/z angular velocity, timing, and accuracy status | No derived orientation, posture, activity, or gesture labels | -| Ambient light | Raw illuminance, timing, and accuracy status | No image or environmental content; values vary by hardware | +| Rotation | Raw x/y/z angular velocity, timing, and accuracy status | See the [data dictionary](data-dictionary.md) for what is left out | +| Ambient light | Raw illuminance, timing, and accuracy status | Values vary by hardware | | Proximity | Raw distance/range and the phone's near/far interpretation | Many devices are binary; it does not prove presence or comparable distance | -| Connection type | Wi-Fi/mobile/ethernet/VPN, whether the connection is validated/metered/roaming, bandwidth estimates | No Wi-Fi network names, IP addresses, web addresses, packets, or content | -| Data volume | Total bytes and packets your device sent and received over Wi-Fi and mobile data during a time window | Coarse and possibly delayed; not per-app and not per-website usage | +| Connection type | Wi-Fi/mobile/ethernet/VPN, whether the connection is validated/metered/roaming, bandwidth estimates | See the [data dictionary](data-dictionary.md) for what is left out | +| Data volume | Total bytes and packets your device sent and received over Wi-Fi and mobile data during a time window | Coarse and possibly delayed | | App and screen use | Package names, app resumed/paused/stopped, screen, keyguard (lock screen), and boot/shutdown events | Android's own record of these can be delayed or incomplete | | Location | Latitude and longitude, source time, accuracy, speed, altitude, bearing, and a mock-location flag | Can be inaccurate or have gaps, depending on your phone and surroundings | | Keyboard touch | Position within a key, timing, pressure, touch size, orientation, tool type, and key category | Does not store the actual characters or text, but the touch pattern itself still carries privacy risk | @@ -229,7 +229,7 @@ Tap anywhere on a row that is not granted yet and the app sends you straight to Used for three things: scheduled study activities, the notification that stays visible while collection is running, and a once-a-day reminder of where the study stands. Activities use Android's background work system, which is not an exact alarm: battery saving, Doze, or system scheduling can delay them. -The daily reminder says either that the study is still collecting, or that it is paused and since when. It exists for the second case: a pause changes nothing else on the phone, so a study you meant to resume can sit stopped for weeks without anything saying so. It is a quiet notification — no sound — and it names only the app and the state, never the study, so it discloses nothing to someone glancing at your lock screen. It stops when the study finishes or you withdraw. +The daily reminder says either that the study is still collecting, or that it is paused and since when. It exists for the second case: a pause changes nothing else on the phone, so a study you meant to resume can sit stopped for weeks without anything saying so. It is a quiet notification, with no sound. It names only the app and the state, never the study, so it discloses nothing to someone glancing at your lock screen. It stops when the study finishes or you withdraw. ### Sensor hardware (感測器硬體) and basic network state @@ -240,13 +240,13 @@ network-state permission and has no row of its own. ### Usage access (使用情況存取權) -Data volume and app and screen use need "Usage access" — a special Android setting that lets an app see which apps have been in the foreground and how much data the device has used. You grant it on an Android system screen, not inside this app, and Android controls that screen. +Data volume and app and screen use need "Usage access". That is a special Android setting, and it lets an app see which apps have been in the foreground and how much data the device has used. You grant it on an Android system screen, not inside this app, and Android controls that screen. You can turn it back off later. The affected source then stops receiving data and reports a problem. It does not invent replacement values for the gap. ### Precise location (精確位置) and Background location (背景位置) -Android may ask about precise location and background location separately. These are requested only if the study actually lists a location source. Continuous background collection is accompanied by a visible foreground-service notification — a notification Android requires an app to show while it does ongoing work — so you can see when collection is running. You can decline optional location, or stop it later with pause or withdrawal. +Android may ask about precise location and background location separately. These are requested only if the study actually lists a location source. Continuous background collection is accompanied by a visible foreground-service notification, the kind Android requires an app to show while it does ongoing work. That is how you can see when collection is running. You can decline optional location, or stop it later with pause or withdrawal. ### Enable the research keyboard (啟用研究鍵盤) and Select the research keyboard (選用研究鍵盤) @@ -256,7 +256,7 @@ Only touches on this keyboard's own surface are visible to it. The app does not ## 5. Important warning about the research keyboard -Research events from the keyboard do not include the actual key identity, the text you submitted, the surrounding text, the clipboard, or suggestions. When a field is a password field, or an app marks a field as private or as no-personalized-learning, touch collection is switched off for that field and the keyboard shows "Touch capture disabled for this field". +Research events from the keyboard do not include the actual key identity, the text you submitted, the surrounding text, the clipboard, or suggestions. Some fields are excluded: a password field, or a field an app marks as private or as no-personalized-learning. Touch collection is switched off for that field, and the keyboard shows "Touch capture disabled for this field". **"No text" does not mean "no risk."** The key category, the relative position of your touch, and the timing can still reveal patterns in what you type. A skilled analyst working with such data can infer more than a list of field names suggests. Third-party apps also sometimes fail to mark sensitive fields correctly, and when that happens, the field is not protected by the rule above — the app has no way to detect the mistake. @@ -285,7 +285,7 @@ After you restart your phone, only a study that was Collecting (收集中) tries ### Scheduled activities and surveys -Each scheduled activity has a durable occurrence identity. Restarting the phone, reopening the app, changing time zone, pausing, or recovering WorkManager reschedules that same occurrence instead of creating another one. Android can deliver a notification late, but the app records separately when it was scheduled, posted, opened, submitted, or expired; a notification being posted is not evidence that you saw it. +Each scheduled activity has a durable occurrence identity. Restarting the phone, reopening the app, changing time zone, pausing, or recovering WorkManager reschedules that same occurrence instead of creating another one. Android can deliver a notification late, but the app records separately when it was scheduled, posted, opened, submitted, or expired. A notification being posted is not evidence that you saw it. Some studies use signed random local-time windows. The phone chooses an instant locally and stores it before scheduling; a retry or reboot does not draw a new time, and the research team cannot send @@ -309,11 +309,11 @@ One panel, in this order: the sources, the counter, the controls, export, delete **The sources.** One row each, in the order the study lists them: a coloured dot, the source's icon, and its name — the same names and icons as the Data step in section 3. Teal means it is collecting, grey means it is stopped or paused, red means it needs your attention. -A source that is behaving gets no words, only its dot. When one needs attention, **a raw code appears in red at the end of its row** — `ACCESS_UNAVAILABLE` when the access it needs is not granted, or a code such as `STORAGE_WRITE_FAILED`, `USAGE_ACCESS_REVOKED`, or `NETWORK_STATS_QUERY_FAILED` when something else went wrong. The app does not translate these or soften them, in either language. They are for quoting to your research team, and they contain none of your collected data. +A source that is behaving gets no words, only its dot. When one needs attention, **a raw code appears in red at the end of its row**. It is `ACCESS_UNAVAILABLE` when the access it needs is not granted, or a code such as `STORAGE_WRITE_FAILED`, `USAGE_ACCESS_REVOKED`, or `NETWORK_STATS_QUERY_FAILED` when something else went wrong. The app does not translate these or soften them, in either language. They are for quoting to your research team, and they contain none of your collected data. **The counter and the sending bar.** Under the sources is the number of events recorded and encrypted so far, across all sources, written as 1,234 events (1,234 筆). -If the study sends data automatically and at least one event exists, the right-hand end of that same line shows 1,200 sent (已送出 1,200) — how many of those events the research team has confirmed receiving — and a bar underneath fills in the same proportion. An empty bar means nothing has been confirmed yet; a full bar means everything collected so far has arrived. In a study that does not send automatically, there is no bar and no sent figure. +If the study sends data automatically and at least one event exists, the right-hand end of that same line shows 1,200 sent (已送出 1,200). That is how many of those events the research team has confirmed receiving, and a bar underneath fills in the same proportion. An empty bar means nothing has been confirmed yet; a full bar means everything collected so far has arrived. In a study that does not send automatically, there is no bar and no sent figure. When a send attempt fails, a code in red replaces the sent figure: @@ -329,12 +329,11 @@ When a send attempt fails, a code in red replaces the sent figure: | `UPLOAD_FAILED` | Anything else | **A send failure does not stop collection.** The study keeps collecting and your phone keeps the -staged encrypted package. Connection failures and server-busy responses are retried using exactly -the same bytes, so the receiver can recognize a replay even when its success response was lost. -A redirect, an incomplete-success response, most other client-error responses, or a mismatched -receipt is recorded as a terminal delivery error instead of being retried forever. The app does -not mark those events sent or silently discard them; the research team must correct its study or -receiver setup. +staged encrypted package. Some failures are retried, such as a connection that dropped or a server +that was busy. Others are recorded as a terminal delivery error instead of being retried forever, +and the research team has to correct its study or receiver setup. The app does not mark those +events sent or silently discard them. Which answers from the receiving server are retried, and +which are final, is set out in the [Protocol v1 contract](../protocol/v1/README.md). You do not need to interpret the code. If one is still on screen after several days, quote it to your research team as it appears; it tells them where the attempt failed and contains none of your collected data. @@ -375,7 +374,7 @@ In a study that sends data automatically, this is what each control does to the | Withdraw (退出研究) | Stops permanently | Continues until everything already collected has been sent, then stops on its own | | Delete local data (刪除本機資料) | Already stopped | Stops immediately; anything not yet sent is destroyed with the rest | -Finishing or withdrawing does not strand data the research team was already owed: the app keeps sending what it collected before you stopped, and gives up scheduling once there is nothing left. If you would rather it not be sent, delete the local study data — that is offered once you have finished or withdrawn, and it removes what has not gone out yet. +Finishing or withdrawing does not strand data the research team was already owed. The app keeps sending what it collected before you stopped, and gives up scheduling once there is nothing left. If you would rather it not be sent, delete the local study data. That is offered once you have finished or withdrawn, and it removes what has not gone out yet. ### Pause and resume @@ -401,12 +400,12 @@ Whenever the status is Collecting (收集中), Paused (已暫停), Completed ( 1. Press Export encrypted data (匯出加密資料). 2. Choose a location and file name in Android's file picker. The app suggests a name ending in `.partexp`. -3. Wait for the app to report completion. It shows the code `EXPORT_COMPLETE` in a band under the header. The export's own record is behind the chevron at the bottom of the screen, as Last export (上次匯出) followed by the number of events and the first twelve characters of the file's SHA-256 digest — a fingerprint of the file's exact contents, which the research team can compare against the file they receive to confirm nothing was altered or truncated in transit. +3. Wait for the app to report completion. It shows the code `EXPORT_COMPLETE` in a band under the header. The export's own record is behind the chevron at the bottom of the screen, as Last export (上次匯出) followed by the number of events and the first twelve characters of the file's SHA-256 digest. That digest is a fingerprint of the file's exact contents. The research team can compare it against the file they receive, to confirm nothing was altered or truncated in transit. 4. Share the `.partexp` file only in the way your study's consent document approves. The export file is encrypted with the researcher public key contained in that study's signed configuration. An ordinary file manager cannot show you its contents, and neither can anyone else who does not hold the matching private key. Each export uses a fresh random encryption key. -If you export while the study is still collecting, the app takes a consistent snapshot at that moment and then carries on collecting. A later export contains newer events, and normally repeats the older ones as well. That is the intended design, not a duplicate-file bug. The exception is a study that sends data automatically and has had to make room: an export covers only the events still on the phone, so it will not repeat ones that were already sent and removed. Section 6 describes the message that tells you this has happened. +If you export while the study is still collecting, the app takes a consistent snapshot at that moment and then carries on collecting. A later export contains newer events, and normally repeats the older ones as well. That is the intended design, not a duplicate-file bug. The exception is a study that sends data automatically and has had to make room. Such an export covers only the events still on the phone, so it will not repeat ones that were already sent and removed. Section 6 describes the message that tells you this has happened. Exporting: @@ -457,10 +456,10 @@ Uninstalling the app or clearing its app data also destroys the local keys and d | A scheduled activity does not arrive on time | Check the notification permission and battery-saving settings; interventions use inexact background scheduling | | A survey says it expired or is unavailable | It cannot be submitted after its signed response window; contact the research team if the timing was unexpected | | Export fails | Check that the destination is writable and has enough space, and retry in another location; the study status does not change because of a failed export | -| An `UPLOAD_…` code appears where the sent figure usually is | Collection carries on. Network, timeout, busy-server, and temporary-server errors retry automatically; other protocol/receipt errors can be terminal. Connect to Wi-Fi and charge the phone, then contact the research team and quote a persistent code | +| An `UPLOAD_…` code appears where the sent figure usually is | Collection carries on. Some failures retry automatically and others are final. Connect to Wi-Fi and charge the phone, then contact the research team and quote a persistent code | | Storage failure / paused | The app fails closed and stops accepting events; do not clear the app's data, contact the research team first, or export if you need to | -When the app has something to tell you, it shows a short code in capital letters in a red band directly under the header, beside a solid dot — `STORAGE_WRITE_FAILED`, `CONFIGURATION_IMPORT_FAILED`, `EXPORT_FAILED` and the like. Passing the code to your research team helps them diagnose the problem, and it contains none of your collected data. Two codes in that band are confirmations rather than problems: `EXPORT_COMPLETE` after an export finishes, and `LOCAL_DATA_DELETED` after a deletion. A third, `JOIN_ACTIVE_STUDY`, is a refusal rather than a fault: the phone already holds a study, so the link was not acted on. +When the app has something to tell you, it shows a short code in capital letters in a red band directly under the header, beside a solid dot. `STORAGE_WRITE_FAILED`, `CONFIGURATION_IMPORT_FAILED`, and `EXPORT_FAILED` are examples. Passing the code to your research team helps them diagnose the problem, and it contains none of your collected data. Two codes in that band are confirmations rather than problems: `EXPORT_COMPLETE` after an export finishes, and `LOCAL_DATA_DELETED` after a deletion. A third, `JOIN_ACTIVE_STUDY`, is a refusal rather than a fault: the phone already holds a study, so the link was not acted on. ## 11. State reference @@ -478,7 +477,7 @@ A study moves through nine states internally, but the screen names only four of | `COMPLETED` | Completed (已完成), grey dot | Permanently stopped; you can export, withdraw, or delete | | `WITHDRAWN` | Withdrawn (已退出), grey dot | Permanently stopped; you can export or delete | -Dots 2 and 3 are one state, not two: the app shows the sources and the consent text as separate pages of the same decision, and pressing Continue (繼續) on the Data page changes nothing in the study itself. +Dots 2 and 3 are one state, not two. The app shows the sources and the consent text as separate pages of the same decision, and pressing Continue (繼續) on the Data page changes nothing in the study itself. While the app is still loading, that same place shows Starting up (準備中). It is not a study state. Once loading finishes with no study imported, the line is simply absent: the header is the app's own name and nothing else. @@ -488,12 +487,12 @@ If you need to describe your situation to your research team, the internal names ## 12. If you tested an earlier version of this app -This app used to be called Android Data Collector. The identity Android uses to tell one app from another has moved twice since then, and the key the releases are signed with has been rotated once, so an earlier install is a separate application even where it already carries the name Particeps. As far as your phone is concerned, that older app and this one are two completely separate apps: the older install cannot update into this one, and the rotated signing key would refuse the update even if the identity had stayed the same. They sit side by side, and installing Particeps brings nothing across: no study, no consent, no collected events, and no export record moves from one to the other. The older app keeps running on your phone, with everything it already holds, until you remove it. What an Android Data Collector install can no longer do is deliver: it writes files in the older format, and if its study sends data automatically, the research team's server no longer accepts what it sends. +This app used to be called Android Data Collector. As far as your phone is concerned, an earlier install and this one are two completely separate apps, and that is also true of an earlier install already carrying the name Particeps. The older install cannot update into this one. The two sit side by side, and installing Particeps brings nothing across: no study, no consent, no collected events, and no export record moves from one to the other. The older app keeps running on your phone, with everything it already holds, until you remove it. What it can no longer do is deliver: it writes files in the older format, and if its study sends data automatically, the research team's server no longer accepts what it sends. [CHANGELOG.md](../CHANGELOG.md) records which release changed what, and why no release can update an earlier install in place. -**Ask your research team before you uninstall the older app.** Uninstalling it destroys the key it keeps on your phone, and after that the encrypted data it collected cannot be read by anyone — not by you, not by them. There is no recovery and no backup. If they want what the older app collected, export it from that app while it is still installed and send them the file; tell them which version it came from, because an export written by an Android Data Collector install needs the older tools to open it. Only then uninstall. +**Ask your research team before you uninstall the older app.** Uninstalling it destroys the key it keeps on your phone, and after that the encrypted data it collected cannot be read by anyone — not by you, not by them. There is no recovery and no backup. If they want what the older app collected, export it from that app while it is still installed and send them the file. Tell them which version it came from, because an export written by an Android Data Collector install needs the older tools to open it. Only then uninstall. If your study uses the research keyboard, you have to enable it and select it again for Particeps. Android treats it as a different keyboard, so the setting you made for the older app does not carry over. Section 4 describes the two steps. --- -Related documents: [researcher guide](researcher-guide.md), [system design](system-design.md). +Related documents: [researcher guide](researcher-guide.md), [system design](system-design.md), [threat model](threat-model.md), [data dictionary](data-dictionary.md). diff --git a/docs/researcher-guide.md b/docs/researcher-guide.md index 3025499..8366192 100644 --- a/docs/researcher-guide.md +++ b/docs/researcher-guide.md @@ -1,16 +1,16 @@ # Researcher guide Particeps runs a study from a signed configuration file, so a new study does -not need a new app. You describe the study in JSON — which collectors run and with what +not need a new app. You describe the study in JSON: which collectors run and with what sampling parameters, how long it lasts, what the consent summary says, which interventions and surveys are scheduled, how much local storage it may use, which public key its bundles are encrypted to, -and whether it delivers them to an endpoint on a schedule — sign that file with your study -key, and hand it to participants. The participant app verifies the signature, presents the +and whether it delivers them to an endpoint on a schedule. Then you sign that file with your +study key and hand it to participants. The participant app verifies the signature, presents the study, and runs exactly what the configuration specifies. -v1 ships twelve selectable collectors — app lifecycle, accelerometer, battery state, temporal +v1 ships twelve selectable collectors: app lifecycle, accelerometer, battery state, temporal context, gyroscope, ambient light, proximity, network state, network usage, usage events, -location, and research-keyboard touch dynamics — and runs the complete +location, and research-keyboard touch dynamics. It runs the complete on-device loop on Android 14–17 (`minSdk 34`, `compileSdk`/`targetSdk 37`). Changing which of them a study uses, how often they sample, or how long the study lasts is a configuration change. Adding a collector that does not exist yet is a code change; see @@ -67,7 +67,7 @@ written up. | `gyroscope.v1` | Raw x/y/z angular velocity in rad/s, sensor time, and accuracy | Orientation, posture, activity, or gesture labels. No inference is performed. | | `ambient_light.v1` | Raw illuminance in lux, sensor time, and accuracy | Image content, a calibrated environment across devices, or whether a person is present. | | `proximity.v1` | Raw sensor distance/range and the device's near/far interpretation | Comparable physical distance across devices or presence; many sensors are binary. | -| `network_state.v1` | Transport flags for the default network (`wifi`, `mobile`, `ethernet`, `vpn`), `validated`/`metered`/`roaming`, and optional link bandwidth estimates | SSID, BSSID, IP address, hostname, URL, packet contents, or who the device communicated with. None of it is read. | +| `network_state.v1` | Transport flags for the default network (`wifi`, `mobile`, `ethernet`, `vpn`), `validated`/`metered`/`roaming`, and optional link bandwidth estimates | Who the device communicated with, or anything that identifies the network itself or its traffic. None of it is read; [`data-dictionary.md`](data-dictionary.md) lists the fields exactly. | | `network_usage.v1` | Device-total `rx_bytes`/`tx_bytes`/`rx_packets`/`tx_packets` per transport, over an explicit `[coverage_start_utc_millis, coverage_end_utc_millis]` window | An instantaneous throughput, a per-app attribution, or the precise time traffic occurred. The coverage window is the finest resolution that exists in the data. | | `usage_events.v1` | Raw platform events — activity resumed/paused/stopped, screen interactive/non-interactive, keyguard shown/hidden, device startup/shutdown — with the reporting package name where the platform supplies one | A complete or real-time session stream. The platform delays events, omits events, and does not guarantee that resume and pause pair up. | | `location.v1` | Fused Location fixes with latitude/longitude, per-fix accuracy fields, the fix's own source time, and the platform `mock` flag | That the participant was at that coordinate, or that the track is continuous. Fixes are estimates, sampled and batched, with gaps you did not choose. | @@ -86,8 +86,8 @@ design constraint, not an afterthought: - **Smallest local quota.** `storage.maximum_local_bytes` bounds how much of a participant's data can accumulate on their device before the store refuses writes. -A field-level reference for every collector's payload is in -[`data-dictionary.md`](data-dictionary.md). The adversary model, and what the design does +A field-level reference for every collector's payload, including the fields each collector does +not record, is in [`data-dictionary.md`](data-dictionary.md). The adversary model, and what the design does and does not defend against, is in [`threat-model.md`](threat-model.md). ## 2. Key responsibilities @@ -100,19 +100,21 @@ v1 uses two key pairs with different purposes. They are not interchangeable. | Raw X25519/HPKE key | Decrypts every bundle, exported or uploaded | The signed study configuration, as `export.hpke_public_key` | Both public halves therefore travel inside the configuration, and neither requires an app -build. A configuration certifies itself: the app verifies the signature with the key the -file carries. What that buys you is one published app running any study; what it costs is -that a valid signature proves the configuration is unchanged since signing, not who wrote -it. Section 6 covers the fingerprint you publish so participants can close that gap. +build. A configuration certifies itself. What that buys you is one published app running any +study; what it costs is that a valid signature proves the configuration is unchanged since +signing, not who wrote it. [`threat-model.md`](threat-model.md) describes how the key travels +inside the signed bytes and what the signature does and does not establish. Section 6 covers +the fingerprint you publish so participants can close that gap. The consequences differ, so track them separately. - **Signing private key lost.** You cannot issue or reissue configurations under that key ID. Existing `.partcfg` files already in participants' hands keep working until they - expire. That is true of current-format files only. An `.adccfg` from before the rename to - Particeps does not keep working: Particeps rejects it outright, as does every current tool, - so a configuration signed under the old identity has to be re-signed with the current - tooling regardless of how much of its validity window is left. Recovery means generating a + expire. That is true of current-format files only. A pre-rename `.adccfg` is a rejected + input rather than an old one, so a configuration signed under the retired identity has to be + re-signed with current tooling regardless of how much of its validity window is left. + [`CHANGELOG.md`](../CHANGELOG.md) records which release retired which format, and what each + one asks of someone who already installed a build. Recovery means generating a new key, putting it in the `signer` block of a new configuration, and re-signing — no app release is involved. - **Signing private key leaked.** Anyone holding it can mint a configuration that verifies @@ -136,25 +138,18 @@ revocation and disclosure procedure, and the destruction date. Note that the Android APK signing key is a third, separate key. It is not either of the above. -The private keys under [`researcher-tools/examples`](../researcher-tools/examples) carry an -`INSECURE-` filename prefix because they are committed to a public repository. They exist so -a debug build can exercise the whole loop, and they are equivalent to fully disclosed keys: -anyone can sign a configuration under the `demo-signer-2026` key ID, and anyone can decrypt -an export encrypted to that HPKE key. They must never be used for a study involving real -participants. - -A release build ships no demonstration study — the signed envelope and the code that loads -it are in the app's `debug` source set only — so a participant who installs a release can -run nothing but a configuration you signed and gave them. That is a packaging boundary, not -a trust one: the build pins no signers, so a configuration signed with the demo key would -still verify if someone handed one over. What the participant has to work with in that case -is the consent screen's signer fingerprint and your published copy of it. +The private keys under [`researcher-tools/examples`](../researcher-tools/examples) are +committed to a public repository and are therefore fully disclosed, so they must never be used +for a study involving real participants. A release build ships no demonstration study. That is a +packaging boundary and not a trust one: the build pins no signers, so a configuration signed with +the demo key would still verify if someone handed one over. Both points are set out +in [`researcher-tools/examples/README.md`](../researcher-tools/examples/README.md). ## 3. Use the researcher CLI Requirement: JDK 17. No command overwrites an existing output path. The key, canonicalisation, -and signing commands open their output with `CREATE_NEW`; `decrypt` checks the destination -first and then stages its plaintext through a temporary file, for the reason given in +and signing commands open their output with `CREATE_NEW`. `decrypt` checks the destination +first, then stages its plaintext through a temporary file, for the reason given in section 9. Generate a production signing key: @@ -286,12 +281,12 @@ never placed in filenames or printed. `canonicalize` and `sign` also accept An action is defined once and reused by all of an intervention's triggers. Calendar-relative schedules include pauses; active-running schedules exclude them. Daily local schedules follow the phone's current time zone and are recomputed after time or zone changes. For both daily-local and -random-window schedules, a local minute that does not exist during a DST gap is skipped instead of -shifted outside the signed time; if a minute occurs twice during a DST overlap, the first +random-window schedules, a local minute that does not exist during a DST gap is skipped rather +than shifted outside the signed time. If a minute occurs twice during a DST overlap, the first chronological occurrence is used. Each planned firing has a -SHA-256 `occurrence_id` derived from its configuration, intervention, trigger, and schedule key, so -reboot, process recovery, WorkManager retry, or duplicate execution cannot create a second firing. -Occurrences stop at the study lifetime and expire after their availability window. +SHA-256 `occurrence_id`, derived from its configuration, intervention, trigger, and schedule key. +Reboot, process recovery, WorkManager retry, and duplicate execution therefore cannot create a +second firing. Occurrences stop at the study lifetime and expire after their availability window. While a study is paused, the app removes pending prompt work and visible intervention notifications, and it rejects prompt claims, opens, expiries, and survey submissions. Calendar time and signed availability windows still advance; on resume the app reconciles the durable @@ -302,7 +297,7 @@ A `random_window` trigger fixes one to eight sorted, non-overlapping local-time configuration. The phone uses a CSPRNG to choose each instant and persists the occurrence before WorkManager is scheduled. Process death, retry, and reboot therefore reuse the same choice. If a daily or total cap is smaller than the signed slots, the planner considers local dates in -planning order, then windows in their signed array order, then occurrence ordinals; the first +planning order, then windows in their signed array order, then occurrence ordinals. The first eligible slots consume capacity. A past or DST-nonexistent slot consumes nothing. The CSPRNG chooses the minute inside a selected slot, not which window survives the cap. Already materialized occurrences are never moved after a clock or time-zone change; only future @@ -345,22 +340,22 @@ Per-collector configuration: | `keyboard_touch.v1` | `trajectory_sampling_hz` 1–120 | Both polling collectors, and scheduled delivery, accept a one-minute floor. That floor exists -for piloting: it lets you confirm within a minute that a collector produces events and that a +for piloting. It lets you confirm within a minute that a collector produces events and that a bundle reaches your endpoint, rather than waiting out a quarter of an hour to find out that neither does. Treat a minute as a diagnostic setting rather than a study setting. It costs -battery, and for `network_usage.v1` it does not buy resolution — Android's own accounting is +battery, and for `network_usage.v1` it does not buy resolution: Android's own accounting is coarse and lags, so a one-minute poll gives you finer windows without giving you finer truth. `required: true` means the study cannot start until that access is granted. An optional collector still appears in the data step described below, marked *Optional*, and on the -participant dashboard; when its access is missing it is shown as blocked. The app does not +participant dashboard. When its access is missing it is shown as blocked. The app does not substitute, interpolate, or synthesise data for a blocked collector. ### What the app tells participants each collector does Setup is five steps, one screen each: study, data, consent, access, start. The second of them is not yours. Before the consent text is shown, the app lists every collector the -signed configuration enables and describes each one from its own template — text compiled +signed configuration enables. It describes each one from its own template — text compiled into the app, in the participant's app language, that you can neither write nor edit. Each entry is a name and a description filled in from that study's parameters, so a study @@ -368,12 +363,8 @@ sampling location every ten seconds and one sampling it every ten minutes do not The description states what each source records and selected limits the implementation can guarantee, such as omitted battery identity, text, inference, or presence claims. It is not an exhaustive threat model, so study-specific risks and every additional participant commitment still -belong in your consent document. The parameters that -reach the screen include the accelerometer and gyroscope `sampling_period_us` (as a rate in hertz, -stated as "or more" because Android treats the period as a hint), the ambient-light interval and -threshold, the proximity interval and threshold, the `poll_interval_minutes` of -`network_usage.v1` and `usage_events.v1`, and -`location.v1`'s `interval_millis` and `minimum_displacement_millimeters` (displayed in metres). +belong in your consent document. Which signed parameters reach the screen, and why the +accelerometer entry hedges its rate, are set out in [`threat-model.md`](threat-model.md). Collectors without configuration fields read the same in every study. The exact wording is in [`app/src/main/res/values/strings.xml`](../app/src/main/res/values/strings.xml) and its `values-zh-rTW` counterpart; read it before you write your consent summary, because your @@ -387,7 +378,7 @@ are, what happens to their data if they withdraw, and how to contact you. `conse is where all of that lives; the constraint list above says what it has to cover, and no code can check that it does. -The direction of the constraint is worth noting when you write that summary: because the +The direction of the constraint is worth noting when you write that summary. Because the collector descriptions are the app's and not yours, you cannot phrase a source more mildly than it is. A summary that understates a collector is contradicted by the screen the participant reads immediately before it. Write the summary to agree with the data step, and @@ -396,9 +387,9 @@ check the two against each other while piloting (section 7). ### The app's language, and yours The app's own screens ship in English and Traditional Chinese. They follow the phone's system -language by default, and a picker in the header changes the language for this app alone; it -writes through Android's `LocaleManager`, so it is the same setting as the system's per-app -language screen rather than a second one that can disagree with it. Everything the app +language by default, and a picker in the header changes the language for this app alone. The +picker writes through Android's `LocaleManager`, so it is the same setting as the system's +per-app language screen rather than a second one that can disagree with it. Everything the app authors is translated: the step names, the collector descriptions, the signature and upload disclosures, the dashboard, and the confirmation dialogs. @@ -409,10 +400,11 @@ explicit BCP 47 overrides in the signed localized-text objects. The app selects compatible signed language tag, then the signed default; it never invents a translation. The deployment consequence is real and worth planning for. **A study recruiting across -languages may still need one signed configuration per language for study and consent prose — same collectors, same parameters, -its own consent document version, its own `configuration_id`, and its own signature — with -each participant given the one written in theirs. Keep `experiment_id` shared across them so -the arms are recognisable as one study, and remember that events are de-duplicated on +languages may still need one signed configuration per language for study and consent prose.** +Each carries the same collectors and the same parameters, with its own consent document +version, its own `configuration_id`, and its own signature. Each participant is given the +one written in theirs. Keep `experiment_id` shared across them so the arms are recognisable as +one study. Remember too that events are de-duplicated on `experiment_id` + `configuration_id` + `participant_instance_id` + `sequence_number` (section 10), so the split reaches your analysis. Telling a participant to switch the app's language does not change a single word you wrote. @@ -424,9 +416,9 @@ participant exports a bundle and sends it to you. A populated block adds schedul of the same encrypted bundles to an endpoint you run. It answers two problems that manual export does not: -- **Timeliness.** You see data during the study rather than after it, so a misconfigured +- **Timeliness.** You see data during the study rather than after it. A misconfigured collector, an access grant that was never completed, or a device that stopped reporting is - visible while you can still act on it. + therefore visible while you can still act on it. - **Resilience.** Data that has been delivered survives a lost, broken, wiped, or never-returned phone. Nothing on the device is recoverable once its Keystore key is gone, and a participant who stops responding takes an un-exported dataset with them. @@ -453,21 +445,21 @@ battery that is not low. Those constraints are why `interval_minutes` is a floor promise: a phone on mobile data all week delivers nothing until it reaches Wi-Fi. Delivery continues while the study is `PAUSED`, for data collected before the pause, and it -continues after the study ends: finishing, completing on the duration deadline, and withdrawing -cancel future interventions and the study deadline but leave delivery running, so an undelivered tail still -reaches you. The chain stops renewing once the study is `COMPLETED` or `WITHDRAWN` and +continues after the study ends. Finishing, completing on the duration deadline, and withdrawing +cancel future interventions and the study deadline, but they leave delivery running, so an +undelivered tail still reaches you. The chain stops renewing once the study is `COMPLETED` or `WITHDRAWN` and everything it collected has been delivered. Deleting local data cancels delivery outright, so plan for a tail you may never receive and keep manual export in your protocol as the fallback. -**How much each run sends.** Before opening HTTP, the app selects an exact event boundary, creates -one complete `PTCEXP01` bundle under its no-backup directory, flushes it to durable storage, and -records a manifest containing its bundle UUID, exact range/count, byte count, and SHA-256. One -outbox entry exists at a time. Its target plaintext budget is about 16 MiB and its hard wire limit +**How much each run sends.** Before opening HTTP, the app selects an exact event boundary and +creates one complete `PTCEXP01` bundle under its no-backup directory. It flushes that bundle to +durable storage and records a manifest containing its bundle UUID, exact range/count, byte count, +and SHA-256. One outbox entry exists at a time. Its target plaintext budget is about 16 MiB and its hard wire limit is 32 MiB. Process death, reboot, timeout, and response loss reuse the same bytes; the app never regenerates ciphertext for a retry. Chunk boundaries are therefore read from the bundle or receipt, not derived from cadence or an expected event count. -**What upload does not do.** It does not gate collection: a study whose endpoint is down, +**What upload does not do.** It does not gate collection. A study whose endpoint is down, misconfigured, or never deployed keeps recording, and a delivery failure is not treated as a collection incident on the participant's screen. @@ -475,8 +467,8 @@ collection incident on the participant's screen. redirect, `202`, every other `4xx`, malformed receipt, or receipt mismatch is a terminal failure for that staged bundle. It remains explicit and collection continues; the app does not silently drop the staged bytes or advance the watermark. The dashboard's fixed incident code is derived -from transport state rather than response content, so support can distinguish DNS, connection, -TLS, timeout, I/O, and HTTP failures without logging a participant identifier. +from transport state rather than response content. Support can therefore distinguish DNS, +connection, TLS, timeout, I/O, and HTTP failures without logging a participant identifier. **What confirmed delivery does to local storage.** A study that comfortably fits its quota keeps every event on the phone. Once storage passes 80% of `storage.maximum_local_bytes`, a @@ -487,7 +479,7 @@ would without an endpoint. Size the quota for the study you are running, not on assumption that delivery keeps it clear — a phone that spends a month off Wi-Fi delivers nothing and reclaims nothing. -The research consequence is in section 10: once a participant's device has reclaimed a +The research consequence is in section 10. Once a participant's device has reclaimed a prefix, their manual export covers a window rather than the whole study, so an uploading study's dataset is the reassembled chunks plus that final export. @@ -506,19 +498,18 @@ These are the only Particeps routing headers: | `X-Particeps-Event-Count` | Exact claimed event count | `Content-Length` and `Content-Digest` are also required. The `X-Particeps-*` headers are untrusted -routing claims: the receiver can check their syntax, arithmetic, body digest, and the identities -exposed by the outer framing, but cannot authenticate the encrypted participant or sequence claims. +routing claims. The receiver can check their syntax, arithmetic, body digest, and the identities +exposed by the outer framing; it cannot authenticate the encrypted participant or sequence claims. No `participant_instance_id`, `assigned_participant_id`, `experiment_id`, or `configuration_id` appears in the URL or headers. Do not invent such a header or deduplicate ingestion by a participant/range pair. Receiver replay identity is the bundle UUID plus exact stored bytes and metadata. -After durable storage, a new object returns `201 Created`; an exact replay returns `200 OK` with -the original response bytes. Both carry compact canonical JSON containing exactly `bundle_id`, -`byte_count`, `configuration_sha256`, `event_count`, `first_sequence_number`, -`last_sequence_number`, and `sha256`, with counts and sequence values as decimal strings. Only a -receipt that matches every outbox value advances the watermark. A reused bundle ID with different -content is `409 Conflict`; `202` is never success. +Your endpoint has to answer with the receipt Protocol v1 defines, because the device advances +its delivery watermark only when every value in that receipt matches the bundle it staged. An +endpoint that improvises a response stalls delivery rather than losing data. The status codes, +the exact receipt fields, and the replay and conflict rules are in the +[Protocol v1 contract](../protocol/v1/README.md). **The participant instance ID.** A fresh random UUID generated on the device for every import, stored in that study's encrypted metadata, and included in every decrypted bundle. It is @@ -529,11 +520,11 @@ Treat it as personal data because it links every event one import produced, but receiver authentication. **You must disclose upload in your consent text.** The app renders the endpoint host, the -cadence, the network condition, the fact that only your key can open the payload, and the -fact that a random installation code travels inside the encrypted data so datasets can be +cadence, and the network condition. It also states that only your key can open the payload, and +that a random installation code travels inside the encrypted data so datasets can be distinguished. This block sits directly below your summary and is derived from signed state rather -than your prose. That is a floor, not a substitute — the -same relationship the data step has to your summary: your consent document has +than your prose. That is a floor, not a substitute, in the +same relationship the data step has to your summary. Your consent document has to say who operates the endpoint, where it is hosted, what jurisdiction it sits in, how long chunks are retained there, and who can reach them. A participant cannot decline upload while accepting the study, so the decision to participate is the decision to be uploaded — write @@ -555,8 +546,8 @@ Canonicalise first: ``` Canonicalisation emits RFC 8785 JCS bytes. The signing step parses the file again and refuses it -unless re-encoding produces exactly the same bytes, so duplicate members, noncanonical numbers, -unknown fields, and hand-edited near-canonical drafts fail closed. +unless re-encoding produces exactly the same bytes. Duplicate members, noncanonical numbers, +unknown fields, and hand-edited near-canonical drafts therefore fail closed. Sign the canonical bytes: @@ -571,13 +562,13 @@ Sign the canonical bytes: `--key-id` must equal the configuration's `signer.key_id`, and the private key you pass must be the one whose public half the configuration declares. Both are checked before anything is written, because a mismatch would produce a file that signs cleanly and then fails on every -device: the second failure reads `signer.public_key in the configuration does not match +device. The second failure reads `signer.public_key in the configuration does not match --private`. -The result is a signed study configuration: a `PTCCFG01` envelope containing magic, a two-byte -signer-key-ID length, a four-byte configuration length, the key ID, exact JCS configuration bytes, -and a fixed 64-byte Ed25519 signature over only those configuration bytes. No signature-length -field or alternate framing is accepted. +The result is a signed study configuration: a `PTCCFG01` envelope carrying the signer key ID, +the exact JCS configuration bytes, and an Ed25519 signature over only those bytes. The +[Protocol v1 contract](../protocol/v1/README.md) gives the exact framing, and no other framing +is accepted. On success the command prints the IDs it signed and the fingerprint of the signing key, for example: @@ -586,9 +577,9 @@ signed my-study-2026 my-study-config-01 fingerprint 9D0D AE5A 0D20 B29F D642 942A 0E17 4AAE ``` -That fingerprint is SHA-256 over the raw 32-byte public key, truncated to 16 bytes and rendered -as eight groups of four hex characters. It is what the consent screen shows the participant, -and what you publish in your recruitment material — section 6. +That fingerprint is what the consent screen shows the participant, and what you publish in your +recruitment material — section 6. How it is derived from the signing key is in +[`threat-model.md`](threat-model.md). Verify independently — envelope structure, signature, client build floor, platform, and validity window — before anything reaches a participant: @@ -624,9 +615,8 @@ already been distributed. ## 6. Build and distribute -```bash -./gradlew test testDebugUnitTest lintDebug assembleDebug assembleRelease -``` +Build and check the app with the command block in +[`CONTRIBUTING.md`](../CONTRIBUTING.md), which is the same one CI runs. Debug APKs are for internal testing only. For real deployment use the tag-triggered GitHub Actions release workflow; the required secrets and setup are described in the repository @@ -636,10 +626,10 @@ Google Play distribution uses the corresponding AAB and track process. Building not part of issuing a study: the same build verifies any correctly signed `.partcfg`. Participants can import the `.partcfg` through the system file picker, or open an immutable -`particeps://join/v1` link / QR generated by the Web authoring surface. Join hosting is transport only: -the link fixes the artifact's complete SHA-256 and signer fingerprint, and the app downloads once, -verifies the digest before the ordinary signature flow, and never polls for replacement. Getting -data back is manual unless the study declares an upload endpoint, in which case delivery is +`particeps://join/v1` link / QR generated by the Web authoring surface. Join hosting is transport +only. The link fixes the artifact's complete SHA-256 and signer fingerprint, and the app downloads +once, verifies the digest before the ordinary signature flow, and never polls for replacement. +Getting data back is manual unless the study declares an upload endpoint, in which case delivery is automatic and manual export remains available alongside it. Plan both directions separately. The app declares `android.permission.INTERNET` and sets `usesCleartextTraffic="false"`, so @@ -663,10 +653,11 @@ you are about to sign, and against what your consent document tells participants After signing in the Web authoring flow, enter the HTTPS location where the exact `.partcfg` bytes will be served. The browser creates the join URI and QR locally; it does not call a QR service. The -artifact URL must use the narrow Protocol v1 profile: lowercase DNS-style HTTPS host, no credentials, -explicit default port, query, fragment, percent escape, dot segment, or repeated slash, followed by one or -more ASCII filename / token path segments. This restriction keeps Kotlin and browser URL handling -byte-for-byte identical rather than relying on either platform's silent normalization. +artifact URL must use the narrow Protocol v1 profile: a lowercase DNS-style HTTPS host, followed by +one or more ASCII filename / token path segments. That profile excludes credentials, an explicit +default port, a query, a fragment, a percent escape, a dot segment, and a repeated slash. This +restriction keeps Kotlin and browser URL handling byte-for-byte identical rather than relying on +either platform's silent normalization. For a personalized configuration, publish each file at a unique path whose final segment is at least 22 random base64url characters (128 bits or more). Never put the roster code in the path, @@ -686,16 +677,17 @@ second consent path. The consent step shows the key fingerprint under the heading *Configuration signature* (設定檔簽章 when the app is in Traditional Chinese), in a block the app asserts itself below your consent summary. The signer key ID is not on that screen; the fingerprint is what a -participant compares. When the build pins no signer — the shipped default — the block asks the participant to check -the fingerprint against the one their research team published, and notes underneath, quietly, -that a signature shows a file is unaltered rather than who wrote it. None of it is in the error -colour: an unpinned signer is the deployment model rather than a fault, and a screen that cries -wolf on the ordinary case teaches participants to skip the one line you need them to act on. +participant compares. When the build pins no signer — the shipped default — the block asks the +participant to check the fingerprint against the one their research team published. Underneath, +quietly, it notes that a signature shows a file is unaltered rather than who wrote it. None of it +is in the error colour, because an unpinned signer is the deployment model rather than a fault; +[`threat-model.md`](threat-model.md) sets out why that block is written as an instruction rather +than a warning. That last instruction is only actionable if you have published it. Put the fingerprint `sign` printed into the material that recruits participants — the study information sheet, the consent document, the lab page participants were sent to — through the same channel that -reached them, and keep it identical for every configuration signed with that key. A +reached them. Keep it identical for every configuration signed with that key. A participant comparing eight groups of four hex characters is the check that a researcher name and contact in the configuration cannot provide, because those are free text the signer chose. @@ -749,11 +741,11 @@ OEM hardware: - Two exports and two successful decryptions from each of `RUNNING`, `PAUSED`, `COMPLETED`, and `WITHDRAWN`. - If the study uploads: the consent step's upload block against your consent document, a - first successful delivery, decryption of a stored chunk, an endpoint that returns 5xx and - then recovers, an endpoint that returns 400, a phone kept off Wi-Fi for the interval, an - unreachable host so you can see the failure code a participant would report, a backlog large - enough that one run does not clear it and the next resumes where it stopped, and what your - endpoint holds after the participant withdraws. + first successful delivery, and decryption of a stored chunk. Then the failure paths — an + endpoint that returns 5xx and then recovers, an endpoint that returns 400, a phone kept off + Wi-Fi for the interval, an unreachable host so you can see the failure code a participant + would report, a backlog large enough that one run does not clear it and the next resumes + where it stopped, and what your endpoint holds after the participant withdraws. - Fail-closed behaviour with the wrong private key, the wrong configuration, and truncated or modified ciphertext. - Reboot, force stop, low storage, wall-clock changes, Doze, long uptime, and the OEM's @@ -776,7 +768,7 @@ a row of dots showing how far along they are: 2. **Data** — every enabled collector, described by the app from your parameters. 3. **Consent** — your `consent.summary`, then the signature and upload blocks the app asserts itself, then the agreement checkbox. -4. **Access** — the Android access the configured collectors need, one row each; tapping an +4. **Access** — the Android access the configured collectors need, one row each. Tapping an outstanding row opens the screen that grants it, except the motion-sensor check, which is hardware and nothing to grant. Optional ones are labelled, and only the required ones block the next step. @@ -789,24 +781,24 @@ for confirmation. See [`participant-guide.md`](participant-guide.md) for what th From the start press onward the app posts one status reminder a day, for as long as the study is `RUNNING` or `PAUSED`. It is a low-importance notification — no sound — whose title is the -application's own name rather than your study's, and whose single line says either that collection -is still running or that the study is paused and since when. It carries no collector names, no +application's own name rather than your study's. Its single line says either that collection +is still running, or that the study is paused and since when. It carries no collector names, no counts, and nothing you wrote: it arrives every day for the study's whole duration, on a lock screen anyone holding the phone can read. The paused half is why it exists — a pause changes nothing else on the phone, so a study a participant meant to resume can sit collecting nothing for weeks with nothing saying so. -Plan participant contact around it. It is not one of your interventions — the app posts it on its -own, and no configuration field switches it off, rewords it, or adds to it — and the first one +Plan participant contact around it. It is not one of your interventions: the app posts it on its +own, and no configuration field switches it off, rewords it, or adds to it. The first one arrives about a day after the start press. Starting or stopping collection retracts a reminder already on screen rather than posting a replacement, so a paused study is never left asserting that -it is still collecting; finishing, completing on the duration deadline, and withdrawing cancel the +it is still collecting. Finishing, completing on the duration deadline, and withdrawing cancel the schedule and clear the standing notification. It needs notification access, which the access step -requires only when your study schedules interventions, so in a study without them it reaches only -the participants who granted notifications for some other reason. None of that makes the reminder -a guarantee that a participant has been reminded: its timing is best effort rather than an exact -alarm, a force stop blocks it until the app is opened again, and a participant can turn its -channel off in Android's notification settings or revoke notification access, either of which +requires only when your study schedules interventions. In a study without them it therefore reaches +only the participants who granted notifications for some other reason. None of that makes the +reminder a guarantee that a participant has been reminded. Its timing is best effort rather than an +exact alarm, and a force stop blocks it until the app is opened again. A participant can also turn +its channel off in Android's notification settings or revoke notification access, either of which stops the reminder without stopping the study. Researchers must not: @@ -860,9 +852,9 @@ transition history, and every catalog event contract pass does it flush and atom file into place. The AES-GCM tag is verified only at EOF, so failed decryption or semantic verification deletes staging and publishes no partial plaintext. -The bundle is a `PTCEXP01` container: a per-bundle AES-256-GCM content key wrapped with RFC 9180 -base-mode X25519/HKDF-SHA256/AES-256-GCM HPKE, over one authenticated JCS document with this -shape: +The bundle is a `PTCEXP01` container. Its framing and its cryptographic suite are specified in +the [Protocol v1 contract](../protocol/v1/README.md). What `decrypt` writes out is one +authenticated JCS document with this shape: ```text bundle_id, bundle_kind, format outer UUID, manual_export/automatic_upload, particeps-research-bundle-v1 @@ -894,18 +886,19 @@ pseudonymous per-import identifier described in section 4. A personalized export carries `assigned_participant_id`; use it only as the researcher's opaque join key. All sequence, count, wall-time, monotonic-time, byte-count, and client-version values are -canonical decimal strings. Every value inside `fields` is also a JSON string, but its exact field +canonical decimal strings. Every value inside `fields` is also a JSON string. Its exact field set, type interpretation, units, clock basis, and bound come from [`protocol/v1/collector-catalog.json`](../protocol/v1/collector-catalog.json); do not infer a schema from observed data. The embedded configuration, its digest, its original signature, producer, outer identities, range/count contiguity, and every catalog payload are verified before plaintext is published. -The JCS context binds `particeps-research-bundle-v1`, bundle UUID, full configuration SHA-256, and -researcher key ID into HPKE `info` and document AES-GCM AAD. A wrong key, context, framing byte, -embedded identity, or artifact predating this definition — a pre-rename `.adcexp` among them — -fails closed. This Protocol v1 definition is a destructive pre-1.0 replacement; there is no -former-v1 fallback. +Both cryptographic layers are bound to the bundle's own identity, so a wrong key, context, +framing byte, or embedded identity fails closed; the +[Protocol v1 contract](../protocol/v1/README.md) gives the exact binding. An artifact predating +this definition fails closed too — a pre-rename `.adcexp` among them — because Protocol v1 is a +destructive pre-1.0 replacement with no former-v1 fallback, as +[`CHANGELOG.md`](../CHANGELOG.md) records. Successful validation proves encryption to the configured researcher key, document integrity, and the provenance of the exact embedded signed configuration. It proves nothing about the @@ -935,8 +928,8 @@ An export is a snapshot, not a state change: - A later export contains the earlier events plus newer ones. Overlap between bundles from the same participant is expected, not an error. Uploaded chunks are the exception: each one starts after the sequence the previous delivery confirmed, so consecutive chunks abut - rather than overlap. Where they abut is selected before the immutable outbox bundle is staged, - so chunk sizes vary and are not derivable from the configuration — take the window + rather than overlap. Where they abut is selected before the immutable outbox bundle is staged. + Chunk sizes therefore vary and are not derivable from the configuration; take the window from `first_sequence_number` and `last_sequence_number`. - In a study that does not upload, every export is a whole history and the last one is the dataset. In an uploading study it need not be: once a device has reclaimed a delivered @@ -952,8 +945,8 @@ An export is a snapshot, not a state change: they are stable across exports and uploads alike, and reclaiming never reissues one. In an uploading study, `participant_instance_id` is what separates repeated imports and devices. - A gap in the delivered sequence range is not proof of data loss. A chunk may not have been - delivered yet, or may have been cut short when the study ended, and events below a - participant's retained floor were released only because your endpoint confirmed them — look + delivered yet, or may have been cut short when the study ended. Events below a + participant's retained floor were released only because your endpoint confirmed them, so look for them in the chunks you already hold. Ask for a manual export before treating a gap as missing data. - Reconstruct running and paused windows from `transitions` together with @@ -1032,7 +1025,7 @@ data. `COMPLETED` means collection has stopped and the data can still be exported. `WITHDRAWN` means the participant has permanently ended participation; they may still export first, or delete local data directly. Both cancel reminders and the study deadline but leave scheduled -delivery running, so a study that ends with an undelivered backlog still sends it; the job +delivery running, so a study that ends with an undelivered backlog still sends it. The job retires itself once the backlog is gone. Deleting local data cancels delivery outright — a participant who deletes before the backlog clears keeps whatever had not yet been sent off your endpoint entirely. Local deletion is only diff --git a/docs/system-design.md b/docs/system-design.md index 0afd286..cd75698 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -7,8 +7,7 @@ download or execute arbitrary code. Collection and storage never require the net a researcher endpoint is an option a study configuration turns on. The [normative Protocol v1 contract](../protocol/v1/README.md) and its -[collector catalog](../protocol/v1/collector-catalog.json) define the wire and event schemas. The -the [protocol specification](../protocol/v1/README.md) is normative for the wire format, and +[collector catalog](../protocol/v1/collector-catalog.json) define the wire and event schemas. [`assurance`](../assurance/README.md) defines the static Collector capability policy. This document explains how the current modules realize those contracts. @@ -19,11 +18,11 @@ explains how the current modules realize those contracts. localized surveys, intervention actions and triggers, local storage quota, export key, and upload. - Every study event is encrypted on the device before it is stored, and nothing leaves the device in plaintext. -- Data reaches the researcher two ways: an export the participant directs, and — when the - configuration carries a populated `upload` block — scheduled delivery of the same encrypted - bundles to the endpoint it names. Both are ciphertext wrapped to the researcher's HPKE key. - Upload is a property of the study, not a participant setting, and it is disclosed on the consent - screen from the signed bytes. +- Data reaches the researcher two ways. The first is an export the participant directs. The second + is scheduled delivery of the same encrypted bundles to the endpoint the configuration names, and + it exists only when that configuration carries a populated `upload` block. Both are ciphertext + wrapped to the researcher's HPKE key. Upload is a property of the study, not a participant + setting, and it is disclosed on the consent screen from the signed bytes. - Collection begins only after the participant explicitly starts it. The participant can pause, resume, finish early, withdraw, export, and delete. Collection never depends on upload succeeding. @@ -101,32 +100,31 @@ construction. `CollectorDashboard.kt` renders the whole participant surface from `StudyUiState`. Setup is a fixed sequence of five steps — study, data, consent, access, start — mapped from the study state by -`setupStep`, with exactly one panel on screen at a time and a row of dots for position; the step +`setupStep`. Exactly one panel is on screen at a time, with a row of dots for position. The step names exist only as that row's content description, since sighted readers get position from the dots -and content from the panel. `CONSENT_PENDING` covers two of those steps, data and consent, and -re-entering it resets to the data step, so the agreement checkbox is never reached without the list +and content from the panel. `CONSENT_PENDING` covers two of those steps, data and consent. +Re-entering it resets to the data step, so the agreement checkbox is never reached without the list of sources having been shown. Once setup is over the header shows the study state and elapsed time instead, and the panel becomes collector health, an event meter, and the lifecycle controls. `CollectorSummary.kt` is what the data step renders: one template per collector type, filled from the signed configuration's own parameters. A summary carries a glyph, a name, that one detail line, -and whether the collector is optional; the panel shows the glyph, the name, an `Optional` tag when -the configuration does not mark the collector required, and the detail. Several of the detail -templates end in a limit — battery context is not battery health or hardware identity, a time zone -is not a location claim, rotation has no orientation or activity inferred from it — but that -clause is part of the detail text rather than a separate field, and the collectors whose template -does not carry one state nothing about what the source cannot see. The per-collector statement of -what a source cannot establish is the table in [`researcher-guide.md`](researcher-guide.md), which -is documentation for the researcher designing the study and not something the app renders. The -summaries are app-authored text with no configuration field behind them — see -[`threat-model.md`](threat-model.md). +and whether the collector is optional. The panel shows the glyph, the name, an `Optional` tag when +the configuration does not mark the collector required, and the detail. The summaries are +app-authored text with no configuration field behind them, so no signed field can change their +wording. [`threat-model.md`](threat-model.md) owns that integrity property and states how far it +reaches. Several of the detail templates end in a limit, but that clause is part of the detail text +rather than a separate field. The collectors whose template does not carry one state nothing +about what the source cannot see. The per-collector statement of what a source cannot establish is +the table in [`researcher-guide.md`](researcher-guide.md), which is documentation for the researcher +designing the study and not something the app renders. Every participant-facing string is a resource; none is written into Kotlin. The app ships English (the default) and Traditional Chinese, declared in `res/xml/locales_config.xml` and referenced by the manifest's `android:localeConfig`. `AppLocale` reads the offered list from that manifest -declaration rather than from a second list in code, and its picker reads and writes -`LocaleManager.applicationLocales` — the same store Android's per-app language screen edits, so the -two cannot disagree, and an empty override means the app follows the system language. Adding a +declaration rather than from a second list in code. Its picker reads and writes +`LocaleManager.applicationLocales`, the same store Android's per-app language screen edits, so the +two cannot disagree. An empty override means the app follows the system language. Adding a language is a `values-*` directory and one line of XML. Researcher-supplied text — title, purpose, researcher name, contact, and the consent summary — is never translated; it renders exactly as it was signed. @@ -147,11 +145,12 @@ magic(8) | signerKeyIdLength(u16) | configLength(u32) | signerKeyId | canonicalConfig | Ed25519Signature(64) ``` -The signing public key travels inside the signed bytes, as a mandatory root `signer` block of -`key_id` and an unpadded-base64url raw 32-byte Ed25519 `public_key`. The export block similarly -carries a raw 32-byte X25519 key. X.509, PKCS#8, Tink JSON/protobuf keysets, padded base64, and -standard-base64 are invalid wire values. A configuration therefore certifies itself, which -is what lets one published app verify any researcher's study. +A configuration certifies itself, which is what lets one published app verify any researcher's study +without a rebuild. Both public keys ride inside the signed bytes, so the same signature covers them +and neither needs separate distribution. How that key travels, and what it does and does not +establish, is set out in [`threat-model.md`](threat-model.md). The exact blocks and encodings are +normative in the [protocol specification](../protocol/v1/README.md). X.509, PKCS#8, Tink +JSON/protobuf keysets, padded base64, and standard-base64 are invalid wire values. Verification order: @@ -170,22 +169,20 @@ the exact canonical bytes, signer key ID and signature, configuration SHA-256, t and `signerAnchored`. That preserved provenance is embedded and reverified in every bundle. `ConfigurationVerifier` takes a map of pinned signers, `CollectorApplication.TRUSTED_SIGNING_KEYS`. -Empty is the shipped default: any correctly signed configuration is accepted and `signerAnchored` is -false, and the consent screen asks the participant to check the signer fingerprint themselves. A -non-empty map is strictly exclusive — any signer not listed is rejected outright — and at step 4 the +Empty is the shipped default. Any correctly signed configuration is then accepted, `signerAnchored` +is false, and the consent screen asks the participant to check the signer fingerprint themselves. A +non-empty map is strictly exclusive: any signer not listed is rejected outright. At step 4 the pinned key wins over the declared one and must equal it, so a configuration cannot claim a pinned key ID while carrying a different key. What this establishes is that the configuration is unchanged since it was signed. It does not -establish who wrote it unless the build pins that signer; `SignerIdentity.fingerprint` (SHA-256 over -the raw public key, first 16 bytes, as eight uppercase groups of four hex characters) is what a -participant compares against what the research team published. See -[`threat-model.md`](threat-model.md). +establish who wrote it unless the build pins that signer. `SignerIdentity.fingerprint` is what a +participant compares against what the research team published; its derivation, and how much that +comparison is worth, are in [`threat-model.md`](threat-model.md). -The researcher HPKE public key needs no separate distribution either: it sits inside the signed -bytes, so the same signature covers it. The signing private key and the HPKE private key have -separate purposes and must not be shared. A build embeds public keys only — and none at all unless -it pins a signer; it contains no study private key. +The signing private key and the HPKE private key have separate purposes and must not be shared. A +build embeds public keys only, and none at all unless it pins a signer. It contains no study +private key. ### Immutable join import @@ -278,15 +275,16 @@ others. | `location.v1` | Fused Location fixes: latitude, longitude, source time, accuracy, speed, altitude, bearing, mock flag | Fine and background location, per the `required` flags in the configuration | | `keyboard_touch.v1` | Touch position relative to the bounds of the pressed key, timing, pressure, size, orientation, tool type, key category | The study input method must be enabled and selected | -Network state records no SSID, BSSID, IP address, DNS server, URL, packet, or payload. Network usage -is the coarse device total from Android's `NetworkStatsManager.querySummaryForDevice` with -`subscriberId=null`. It is not an instantaneous rate and not a per-app attribution. +Network usage is the coarse device total from Android's `NetworkStatsManager.querySummaryForDevice` +with `subscriberId=null`. It is not an instantaneous rate and not a per-app attribution. -The keyboard is a working English-letter QWERTY IME, but a study event from it contains no -characters, committed text, surrounding text, clipboard content, or suggestions. Password input -types and `IME_FLAG_NO_PERSONALIZED_LEARNING` disable touch collection entirely for that field. -Within-key touch positions still carry inference risk, so they must be disclosed explicitly in the -consent material. +The keyboard is a working English-letter QWERTY IME, but key identity and text never reach the event +path. Password input types and `IME_FLAG_NO_PERSONALIZED_LEARNING` disable touch collection entirely +for that field. Within-key touch positions still carry inference risk, so they must be disclosed +explicitly in the consent material. + +What each of these collectors does not record, field by field, is the `Not recorded` line under its +entry in the [data dictionary](data-dictionary.md). ## 7. Local storage @@ -304,7 +302,7 @@ claim, not an absolute hardware-protection claim. migrated. - Events: `events-00000001.ptcs` segments capped at 4 MiB. A segment is appended to and never rewritten; whole leading segments can be reclaimed once delivery is confirmed. At most - `MAXIMUM_LIVE_SEGMENTS = 2048` are resident at once, which at 4 MiB each covers the largest + `MAXIMUM_LIVE_SEGMENTS = 2048` are resident at once. At 4 MiB each that covers the largest permitted quota, so the quota is what binds in practice and the segment count stays a backstop. The index is monotone and never reused, bounded by `MAXIMUM_SEGMENT_INDEX = 1_000_000_000`. - Each segment opens with a 12-byte header, `PTCEVT01 | segmentIndex(int32)`, which the reader @@ -315,14 +313,14 @@ claim, not an absolute hardware-protection claim. - An event plus its resulting metadata is one recoverable commit. Before appending, the store writes an encrypted `PTCTXN01` journal containing the resulting metadata before the event append. If the journal is one boundary ahead and its event is durable, recovery authenticates that exact - tail event and commits the journal metadata; if the event is absent, it discards the prepared + tail event and commits the journal metadata. If the event is absent, it discards the prepared journal. A same-boundary leftover is discarded with main metadata authoritative. Any other boundary, malformed journal, or event mismatch fails closed. This is the one write path used for occurrence lifecycle events and survey submissions; there is no independent draft store. - The active signed configuration is held separately, under its own Keystore key, as `PTCACT01 | random 96-bit IV | ciphertext+tag`. - The local quota comes from the configuration and is bounded to 8 MiB-8 GiB. Encoded metadata is - capped at `MAXIMUM_METADATA_BYTES` = 1 MiB, and an append must leave `METADATA_RESERVE_BYTES` = + capped at `MAXIMUM_METADATA_BYTES` = 1 MiB. An append must also leave `METADATA_RESERVE_BYTES` = 2 MiB of the quota free, so the metadata that names the last event that fits can always be rewritten. @@ -364,14 +362,14 @@ configuration has no field that changes them. because a confirmed delivery is the only thing that makes local data reclaimable. `EvictionPlanner` in `:core:storage` chooses what goes. It is a pure function over segment -summaries — index, first sequence, size on disk — so the rules are tested on the JVM rather than -only on a device. A whole segment qualifies only when both of these hold: +summaries: index, first sequence, and size on disk. The rules are therefore tested on the JVM rather +than only on a device. A whole segment qualifies only when both of these hold: 1. **Every event in it was confirmed.** A segment runs to the next segment's first sequence minus one, so it is fully delivered when the next segment starts at or below `uploadedThroughSequence + 1`. 2. **It is not the newest segment.** That one is still being appended to, so its upper bound is - unknown, and keeping it guarantees a reload always finds at least one event. + unknown. Keeping it also guarantees a reload always finds at least one event. A collector's most recent event needs no special treatment. `lastEvents` is persisted in the study metadata rather than rebuilt by scanning, so a polling collector keeps the timestamp it resumes from @@ -379,8 +377,8 @@ even once the segment holding that event is gone. An earlier rule pinned any seg event; it existed only to keep `lastEvents` rebuildable by scanning, and went with that. Segments go oldest first and always form a contiguous leading run. Nothing undelivered is ever -reclaimed: when the quota fills and nothing qualifies, the write fails and the study fail-closes to -`PAUSED`, the same outcome a study without an endpoint reaches. +reclaimed. When the quota fills and nothing qualifies, the write fails and the study fail-closes to +`PAUSED` — the same outcome a study without an endpoint reaches. `StudyMetadata.retainedFromSequence` is the lowest sequence still on the device, and 1 when nothing has been reclaimed. `eventCount` stays the lifetime total, and `nextSequenceNumber` comes from @@ -388,7 +386,7 @@ persisted metadata rather than being recomputed from the scan, so a sequence num reissued after reclaiming. The readable window is `[retainedFromSequence, eventCount]`. The floor is persisted before the segments below it are unlinked. A crash in between leaves more on -disk than the floor claims, which is harmless: the load path adopts the first sequence it actually +disk than the floor claims, which is harmless. The load path adopts the first sequence it actually finds, and the next pass finishes the job. Finding *less* on disk than the floor claims is fatal on load — `Event segments below the retained floor are missing` — because it is indistinguishable from a prefix having been tampered away. @@ -401,79 +399,66 @@ nothing qualified. Both paths use `ResearchExport` and the same authenticated document schema. A manual export reads `[retainedFromSequence, nextSequenceNumber - 1]` and streams directly to the participant's Storage -Access Framework destination, so it may scale to the configured 8 GiB local quota. An automatic -upload first selects an exact non-empty window near a 16 MiB plaintext target, while enforcing a -32 MiB automatic-upload container ceiling. +Access Framework destination. It may therefore scale to the configured 8 GiB local quota. An +automatic upload first selects an exact non-empty window near a 16 MiB plaintext target, while +enforcing a 32 MiB automatic-upload container ceiling. Automatic upload does not stream a newly generated request. `FileUploadOutbox` creates the complete -ciphertext in no-backup storage, flushes and atomically publishes it, then persists a bounded -recovery manifest with bundle ID, exact first/last sequence, event count, byte count, configuration -digest, ciphertext SHA-256, and an optional terminal code. It contains no participant, experiment, -or configuration ID. At most one entry exists. Recovery -accepts it only when manifest, length, digest, and outer framing agree. Process death, reboot, I/O retry, or lost response reuses -the same file byte-for-byte; a new bundle cannot supersede it until an exact receipt commits it. +ciphertext in no-backup storage, then flushes and atomically publishes it. It next persists a +bounded recovery manifest with bundle ID, exact first/last sequence, event count, byte count, +configuration digest, ciphertext SHA-256, and an optional terminal code. That manifest contains no +participant, experiment, or configuration ID. At most one entry exists. Recovery accepts it only +when manifest, length, digest, and outer framing agree. Process death, reboot, I/O retry, or lost +response reuses the same file byte-for-byte. A new bundle cannot supersede it until an exact receipt +commits it. The HTTP body is therefore replayable, has fixed `Content-Length` and `Content-Digest`, and is never chunked. Automatic redirects and OkHttp connection-level request replay are disabled; the outbox and worker own retry semantics. Collection and later manual export can continue while the staged file is pending. -`ResearchExport.decrypt` streams as well, for the same reason: a bundle is bounded by the study's +`ResearchExport.decrypt` streams as well, for the same reason. A bundle is bounded by the study's quota rather than by a fixed ceiling, so it can be larger than a researcher's machine wants to hold in memory. It drives the cipher directly rather than through `CipherInputStream`, which reports an AEAD failure as a normal end of stream and would turn a tampered bundle into a silently truncated file. Plaintext therefore reaches only a mode-`0600` staging file before the tag is verified. `researcher-tools decrypt` then streams that file through -[`ResearchBundleVerifier`](../core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt) -and publishes it -with an atomic move only after the authenticated document, signature, identities, ranges, -transitions, and catalog payloads all pass. - -Export format: - -```text -PTCEXP01 | bundleId(16) | configurationSha256(32) | keyIdLength(u16) | -contentNonce(12) | researcherKeyId | HPKEWrappedContentKey(80) | AES-GCMCiphertext -``` - -- Content: one closed-world JCS `particeps-research-bundle-v1` document containing the outer bundle identity, - manual/automatic kind, exact embedded configuration, configuration digest and original signature, - producer platform/build, snapshot time, full study metadata, transitions, and the exact contiguous - event window. Sequence, count, time, and client-build values are decimal strings. -- Content key: a freshly generated AES-256 key and 96-bit nonce for every bundle. -- Key wrapping: RFC 9180 base mode with X25519/HKDF-SHA256/AES-256-GCM. The fixed 80-byte wire - value is `enc[32] || sealed_key[48]`; library-specific keysets or prefixes never appear. -- Context: exact JCS containing bundle format, bundle UUID, full configuration SHA-256, and - researcher key ID. It is HPKE `info` and document AES-GCM AAD. -- Validation: framing and bounds, HPKE, content AEAD, JCS, outer/inner identities, embedded - configuration digest/signature/platform/build, exact range/count, and catalog payloads all pass - before plaintext-derived output is published. +[`ResearchBundleVerifier`](../core/export/src/main/kotlin/cool/jacoblin/particeps/core/export/ResearchBundleVerifier.kt). +It publishes the result with an atomic move only after the authenticated document, signature, +identities, ranges, transitions, and catalog payloads all pass. + +The `PTCEXP01` container framing, the fixed HPKE suite, and the 80-byte wrapped content key are +normative in the [protocol specification](../protocol/v1/README.md). So are the exact JCS context +that binds both cryptographic layers and the order in which a reader validates them. What +`:core:export` supplies is the content: one closed-world JCS `particeps-research-bundle-v1` +document carrying the outer bundle identity, manual/automatic kind, exact embedded configuration, +configuration digest and original signature, producer platform/build, snapshot time, full study +metadata, transitions, and the exact contiguous event window. Every bundle gets a freshly generated +content key and nonce, and no plaintext-derived output is published until every layer of that +document validates. A state can be exported any number of times, and each file uses a new random key. Repeated exports normally overlap, so the research side partitions by `(experiment_id, configuration_id)` and deduplicates on `(participant_instance_id, sequence_number)`, treating different content at one -identity as a conflict. In a study that has reclaimed space, an export -starts at the retained floor instead of at 1 and its `first_sequence_number` says so, which makes -it a window over the events still on the device rather than the whole history. The wrong private -key, the wrong configuration, or any tampering with the header or the ciphertext leaves the bundle -undecryptable. - -Upload advances a durable watermark rather than repeating history. `StudyMetadata.uploadedThroughSequence` -holds the highest sequence an endpoint confirmed; it starts at 0, advances only after a successful -receipt, and never moves backwards. Requests use `application/vnd.particeps.research-bundle` plus bundle -UUID, format, configuration SHA-256, researcher key ID, exact from/to/count, length, and digest -headers. There are no clear participant, assigned, experiment, or configuration IDs; routing -metadata is explicitly untrusted. - -A new durable object succeeds only with `201 Created`; an exact replay succeeds only with `200 OK`. -Both return the same compact JCS receipt with exactly `bundle_id`, `byte_count`, -`configuration_sha256`, `event_count`, `first_sequence_number`, `last_sequence_number`, and -`sha256`. Every value must equal the outbox manifest before commit. Receipt loss is safe because an -exact replay returns the original receipt; a bundle-ID/content conflict is terminal rather than an -overwrite. - -The session lock is taken twice and briefly — once to compute the range, once to commit — with the -HTTP transfer in between under a separate mutex, so an unresponsive endpoint cannot block the +identity as a conflict. In a study that has reclaimed space, an export starts at the retained floor +instead of at 1, and its `first_sequence_number` says so. That makes it a window over the events +still on the device rather than the whole history. The wrong private key, the wrong configuration, +or any tampering with the header or the ciphertext leaves the bundle undecryptable. + +Upload advances a durable watermark rather than repeating history. +`StudyMetadata.uploadedThroughSequence` holds the highest sequence an endpoint confirmed. It starts +at 0, advances only after a successful receipt, and never moves backwards. Requests carry the +`application/vnd.particeps.research-bundle` media type and the `X-Particeps-*` routing headers the +[protocol specification](../protocol/v1/README.md) fixes. There are no clear participant, assigned, +experiment, or configuration IDs; routing metadata is explicitly untrusted. + +The watermark moves only when the endpoint's receipt matches the durable outbox manifest value for +value, which is what makes a lost response safe and a conflicting one terminal rather than an +overwrite. The success status codes, the receipt's exact members, and the receiver's create-only +write rules are in the [protocol specification](../protocol/v1/README.md). + +The session lock is taken twice and briefly: once to compute the range, once to commit. The HTTP +transfer sits in between under a separate mutex, so an unresponsive endpoint cannot block the participant from pausing or withdrawing. A study withdrawn, deleted, or replaced while a request is in flight discards the commit. Committing the watermark is also where reclaiming is attempted, described in section 7. @@ -481,8 +466,9 @@ described in section 7. A failed delivery sets a reason code on the upload state only; the participant-facing `incidentCode` is left alone, so a transient network problem cannot bury a storage or access incident the participant needs to act on. The code comes from `StudyUploadException`, which carries a fixed -identifier rather than a message and validates it against the same `[A-Z][A-Z0-9_]{2,63}` pattern as -a collector's health reason, so nothing that reaches a screen or a log can hold study data. +identifier rather than a message. It validates that identifier against the same +`[A-Z][A-Z0-9_]{2,63}` pattern as a collector's health reason, so nothing that reaches a screen or a +log can hold study data. `OkHttpStudyUploader` classifies the transport failure into `UPLOAD_TIMEOUT`, `UPLOAD_HOST_UNRESOLVED`, `UPLOAD_CONNECT_REFUSED`, `UPLOAD_TLS_HANDSHAKE_FAILED`, `UPLOAD_TLS_FAILED`, `UPLOAD_INTERRUPTED`, `UPLOAD_IO_FAILED`, or `UPLOAD_FAILED`, and an HTTP error @@ -503,7 +489,7 @@ the same way. restored only when the persisted state was `RUNNING`. - `DailyStatusWorker` posts one low-importance notification a day while the study is `RUNNING` or `PAUSED`. It says either that collection is still running or that the study is paused and since - when, and nothing else: no counts, no collector names, and the title line is the application's own + when, and nothing else: no counts and no collector names. The title line is the application's own name rather than the study title, because this arrives every day and a lock screen is readable by whoever is holding the phone. One notification tag, so today's reminder replaces yesterday's. A run in any other state, or with no configuration, posts nothing. Without `POST_NOTIFICATIONS` the @@ -515,11 +501,11 @@ the same way. periodic work across reboots. `ExistingPeriodicWorkPolicy.KEEP`, so a session initialising again does not push the next reminder a full day away. - The schedule is deliberately not cancelled on pause, since a paused study is the case the - reminder exists for; `cancelCollectionWork` cancels both the schedule and any standing + reminder exists for. `cancelCollectionWork` cancels both the schedule and any standing notification when the study reaches a terminal state — finished early, completed at its - deadline, or withdrawn — and deleting local data cancels it as well. Starting or stopping - collection retracts a standing reminder without posting a replacement, because it states a state - that has just stopped being true and the next daily run posts the truth. Since pause stops the + deadline, or withdrawn. Deleting local data cancels it as well. Starting or stopping collection + retracts a standing reminder without posting a replacement: it states a state that has just + stopped being true, and the next daily run posts the truth. Since pause stops the foreground service and cancels visible prompt notifications, this is the only notification that appears while a study is paused. - Each intervention combines a reusable action with one or more triggers. Actions are localized @@ -537,8 +523,8 @@ the same way. enqueue a second logical occurrence. A configuration is bounded to 512 lifetime occurrences so this exact durable set remains inside the encrypted metadata ceiling. - Prompt lifecycle mutations are accepted only in `RUNNING`. Pause cancels pending intervention - work and visible prompt notifications without freezing calendar time or signed availability; - resume reconciles the durable set, expires elapsed windows, and schedules only still-eligible + work and visible prompt notifications without freezing calendar time or signed availability. + Resume reconciles the durable set, expires elapsed windows, and schedules only still-eligible occurrences. Survey answers cannot be opened or submitted during the pause. - Random-window triggers use a CSPRNG and persist the chosen instant before WorkManager receives it. Restart and retry reuse that record. Time/time-zone changes leave materialized occurrences @@ -563,11 +549,12 @@ the same way. `AndroidStudyWorkScheduler.reschedulePendingWork` re-establishes it whenever a session initialises, including after a boot, with `ExistingWorkPolicy.KEEP` so a link already waiting does not have its delay reset on every app start. -- The worker acts in `RUNNING`, `PAUSED`, `COMPLETED`, and `WITHDRAWN`, and no-ops in every other - state or when the active study is not the one the job was scheduled for. Finishing or withdrawing - cancels interventions and the deadline but leaves delivery running, so a study that has ended still - sends its undelivered tail. The chain is simply not renewed once `uploadDrained()` reports that a - terminal study has nothing outstanding; deleting local data cancels it outright. +- The worker acts in `RUNNING`, `PAUSED`, `COMPLETED`, and `WITHDRAWN`. It no-ops in every other + state, and when the active study is not the one the job was scheduled for. Finishing or + withdrawing cancels interventions and the deadline but leaves delivery running, so a study that + has ended still sends its undelivered tail. The chain is simply not renewed once + `uploadDrained()` reports that a terminal study has nothing outstanding. Deleting local data + cancels it outright. - A retryable run returns `Result.retry()` and keeps the immutable outbox entry. A terminal protocol or receipt failure is persisted explicitly and does not spin forever; collection remains independent and the staged ciphertext is not acknowledged or reclaimed. @@ -593,8 +580,9 @@ the same way. canonicality checks as the collector set. There is no runtime toggle and no way to redirect a study to a different endpoint without a new signature and fresh consent. - The upload watermark advances only for a `201 Created` or exact-replay `200 OK` whose canonical - seven-member receipt matches the durable outbox manifest. `202`, redirects, generic `2xx`, and - malformed or mismatched receipts never commit or make events reclaimable. + seven-member receipt matches the durable outbox manifest, as the + [protocol specification](../protocol/v1/README.md) defines it. `202`, redirects, generic `2xx`, + and malformed or mismatched receipts never commit or make events reclaimable. - Both public keys travel inside the signed configuration; the study signing and export private keys belong in neither the app nor a production repository. - Of the app's own components, only the launcher activity and the boot receiver and IME service that @@ -646,20 +634,20 @@ The instrumentation test defines the full Compose participation flow: importing the shipped empty anchor map, the study step, a Continue through the data step, consent, access setup, start, pause with an assertion that no events are admitted during the pause, resume, and finish through its confirmation dialog. It drives the setup steps by test tag, because the header -shows a position rather than a state name, and the two places it does assert on text — the -confirmation button and the terminal state — read it back through `getString`, so the test passes in -whatever language the device is set to rather than pinning one locale's wording. It runs against the -debug variant, which is the only one that carries the demo study — a release build compiles neither -the signed envelope nor its loader, so the entry point the test drives does not exist there. It -scrolls to the export control but does not perform an export. It has to actually run on an -emulator or a device; assembling the test APK is not a device-test pass. +shows a position rather than a state name. The two places it does assert on text — the confirmation +button and the terminal state — read it back through `getString`. The test therefore passes in +whatever language the device is set to rather than pinning one locale's wording. It runs against +the debug variant, which is the only one that carries the demo study; a release ships none, for the +reasons [`researcher-tools/examples/README.md`](../researcher-tools/examples/README.md) gives. It +scrolls to the export control but does not perform an export. It has to actually run on an emulator +or a device; assembling the test APK is not a device-test pass. Two narrower Android regressions sit beside that UI flow. `AndroidConfigurationImportTest` proves raw-key Ed25519 demo import on Android itself, so a JCA provider-order regression cannot hide behind JVM-only protocol tests. `P2CollectorEmulatorTest` creates the five P2 plugins against real Android broadcast and `SensorManager` surfaces, validates every emitted draft against its Protocol v1 descriptor, and checks pause/resume/stop boundaries. It skips when the test -device lacks gyro, light, or proximity hardware; its explicit `p2SyntheticInputs=true` mode requires +device lacks gyro, light, or proximity hardware. Its explicit `p2SyntheticInputs=true` mode requires host-side emulator injection and checks the fixed readings documented in the root README. Before real recruitment, a study still needs study-specific testing on the target physical devices diff --git a/docs/threat-model.md b/docs/threat-model.md index 0dbb90c..d124327 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -38,9 +38,9 @@ The design assumes all of the following. If one is false, the protections below ### Local study data at rest -Events and metadata are encrypted with AES-256-GCM under a per-study Android Keystore key marked non-exportable, with a fresh provider-generated 96-bit IV per record and a 128-bit tag. Each frame's AAD binds the format tag, an opaque per-study locator derived by SHA-256, and the record's sequence number, so a frame cannot be moved between studies, reordered, or replayed without failing authentication. +Events and metadata are encrypted with AES-256-GCM under a per-study Android Keystore key marked non-exportable, with a fresh provider-generated 96-bit IV per record and a 128-bit tag. Each frame's AAD binds the format tag, an opaque per-study locator derived by SHA-256, and the record's sequence number. A frame therefore cannot be moved between studies, reordered, or replayed without failing authentication. -One detail matters for review: the app does **not** request StrongBox and does **not** verify that the key landed in secure hardware, so this document does not claim the key is hardware-backed. On most current devices an `AndroidKeyStore` AES key is TEE-backed, but the app neither requires nor checks it. The key also carries no user-authentication requirement, so it is usable whenever the app process runs. The protection therefore covers data at rest on a powered-off or locked device, backed by Android's file-based encryption and the Keystore — not an attacker who can run code as the app. +One detail matters for review. The app does **not** request StrongBox and does **not** verify that the key landed in secure hardware, so this document does not claim the key is hardware-backed. On most current devices an `AndroidKeyStore` AES key is TEE-backed, but the app neither requires nor checks it. The key also carries no user-authentication requirement, so it is usable whenever the app process runs. The protection therefore covers data at rest on a powered-off or locked device, backed by Android's file-based encryption and the Keystore — not an attacker who can run code as the app. ### Study configuration integrity @@ -53,21 +53,21 @@ substitute what is signed. High-risk deployments should use the CLI in a control archive the canonical configuration beside the key material, and verify the resulting signer fingerprint through an independent recruitment channel. -A configuration is Ed25519-signed inside a `PTCCFG01` envelope. The signing public key travels inside the signed bytes, in a mandatory `signer` block, so a configuration certifies itself and one published app can verify any researcher's study without a rebuild. +A configuration is Ed25519-signed inside a `PTCCFG01` envelope. The signing public key travels inside the signed bytes, in a mandatory `signer` block. A configuration therefore certifies itself, and one published app can verify any researcher's study without a rebuild. **What a signature proves is that the configuration is unchanged since it was signed.** It does not prove who wrote it. A verified configuration establishes that identity mode, collectors, localized surveys, intervention actions and triggers, duration, consent, export key, and `upload` block are exactly the bytes the signer produced. It establishes nothing about the identity behind that key unless the build pins that signer. -On import the app bounds the fixed `PTCCFG01` framing, strictly decodes and byte-for-byte rechecks RFC 8785 JCS, requires the declared `signer.key_id` to equal the envelope key ID, verifies the fixed 64-byte signature over the exact configuration bytes, and checks validity, Android platform, and the decimal-string client build floor. Ed25519 and X25519 wire keys must be raw 32-byte values encoded as unpadded base64url; X.509, PKCS#8, padded base64, and library keysets are rejected. Nothing decoded is acted on until the signature verifies. Every object has an exact key set and an unknown collector fails even when optional. Protocol v1 is a destructive pre-1.0 replacement: former-v1 artifacts fail rather than entering a compatibility branch. +On import the app bounds the fixed `PTCCFG01` framing and strictly decodes and byte-for-byte rechecks RFC 8785 JCS. It requires the declared `signer.key_id` to equal the envelope key ID, then verifies the fixed 64-byte signature over the exact configuration bytes. It also checks validity, Android platform, and the decimal-string client build floor. Ed25519 and X25519 wire keys must be raw 32-byte values encoded as unpadded base64url; X.509, PKCS#8, padded base64, and library keysets are rejected. Nothing decoded is acted on until the signature verifies. Every object has an exact key set and an unknown collector fails even when optional. Protocol v1 is a destructive pre-1.0 replacement: former-v1 artifacts fail rather than entering a compatibility branch. -A build may additionally pin signers, as `CollectorApplication.TRUSTED_SIGNING_KEYS`. That map is empty in the shipped build, which therefore accepts any correctly signed configuration and reports the publisher as unverified to the participant. A non-empty map is strictly exclusive: only listed signers are accepted, and the pinned key overrides the one the configuration declares and must equal it, so a configuration cannot claim a pinned key ID while carrying a different key. `ConfigurationVerifier` returns both the configuration and whether its signer was pinned, and the consent screen renders that distinction. +A build may additionally pin signers, as `CollectorApplication.TRUSTED_SIGNING_KEYS`. That map is empty in the shipped build, which therefore accepts any correctly signed configuration and reports the publisher as unverified to the participant. A non-empty map is strictly exclusive: only listed signers are accepted. The pinned key overrides the one the configuration declares and must equal it, so a configuration cannot claim a pinned key ID while carrying a different key. `ConfigurationVerifier` returns both the configuration and whether its signer was pinned, and the consent screen renders that distinction. -Failures are fail-closed: a failed import does not activate a study, and a failed recovery at boot lands the app in the no-study state, which collects nothing. The `signerKeyId` in the envelope sits outside the signature and asserts nothing: it must match the key ID inside the signed bytes, so a forged one fails that check, fails to resolve against a pinned map, or fails signature verification. +Failures are fail-closed: a failed import does not activate a study, and a failed recovery at boot lands the app in the no-study state, which collects nothing. The `signerKeyId` in the envelope sits outside the signature and asserts nothing. It must match the key ID inside the signed bytes, so a forged one fails that check, fails to resolve against a pinned map, or fails signature verification. ### Scope of collection Collectors are selected by ID from modules compiled into the APK: no plugin download, scripting, -or dynamic loading. Every parameter has a validated range. The module graph enforces the boundary: -a feature collector depends on `core:collector-api`, `core:study-definition`, and optionally the +or dynamic loading. Every parameter has a validated range. The module graph enforces the boundary. +A feature collector depends on `core:collector-api`, `core:study-definition`, and optionally the narrow `collector:sensor-common` helper, so storage, protocol/export, study-state, and UI code are not on its classpath. @@ -79,15 +79,15 @@ new collector also needs catalog metadata, disclosure, bounds, lifecycle/access and storage estimates. **What the participant is told about that scope is the app's text, not the researcher's.** Setup is -five steps with one panel each — study, data, consent, access, start — and the data step, which comes -before the consent text, lists every collector the signed configuration enables. Each entry is +five steps with one panel each: study, data, consent, access, start. The data step comes before the +consent text and lists every collector the signed configuration enables. Each entry is described from a template compiled into the app and filled in from signed parameters: motion-sensor rates, ambient-light/proximity gates, polling intervals, and location interval/displacement. No configuration field changes the wording, so a researcher cannot understate what a collector captures on the screen a participant reads immediately before consenting. This integrity property constrains each enabled source's description, not the honesty of the researcher's consent prose. -One template hedges on purpose. The accelerometer entry reads "about N times per second **or more**", because Android treats a sampling period as a hint rather than a contract and a device is free to deliver faster than the study asked for — observed on a current emulator image at over ten times the requested rate. Stating the configured rate alone would understate what is recorded. +One template hedges on purpose. The accelerometer entry reads "about N times per second **or more**". Android treats a sampling period as a hint rather than a contract, and a device is free to deliver faster than the study asked for. A current emulator image was observed at over ten times the requested rate. Stating the configured rate alone would understate what is recorded. Every participant-facing app string lives in resources and ships in English and Traditional Chinese. The interface follows Android's per-app language. Ordinary researcher prose — title, purpose, researcher name, contact, and consent summary — renders exactly as signed. Survey content is the explicit exception: every localized value and its signed default are inside the signed configuration, and selection never creates unsigned text. @@ -96,7 +96,7 @@ Every participant-facing app string lives in resources and ships in English and Every import mints a new random UUID, even when the same configuration is imported twice. A personalized configuration may also contain one opaque assigned code restricted to a small ASCII grammar. The consent screen distinguishes the two modes and shows the assigned code for comparison. Both appear in encrypted metadata and bundles. Clear upload URLs and headers contain neither one, nor the experiment or configuration ID; they are not participant or device authentication. An optional `particeps://join/v1` link adds an untrusted HTTPS host only as artifact transport. The link -binds the complete artifact SHA-256 and signer fingerprint; Android disables redirects and implicit +binds the complete artifact SHA-256 and signer fingerprint. Android disables redirects and implicit retry, verifies the digest before the normal signature flow, and never polls for updates. A hostile host can deny service, but changing bytes fails before consent and cannot alter an accepted study. Personalized authoring requires a random opaque path and rejects the assigned ID in the URL. The @@ -104,8 +104,8 @@ URL still reaches the host and may appear in infrastructure logs, so researchers roster identifier into its path or operational labels. The link controls one unauthenticated HTTPS `GET`. Redirects and connection retries are disabled, -no HTTP cookies or participant credentials are attached, the response is tightly bounded, and -no response becomes a study without the pinned digest, signature, and fingerprint. The parser does +no HTTP cookies or participant credentials are attached, and the response is tightly bounded. No +response becomes a study without the pinned digest, signature, and fingerprint. The parser does not resolve the DNS name itself or filter the address to which DNS maps it, however. Opening an untrusted join link can therefore make the participant's device contact an HTTPS service reachable from its current network even though the app neither reveals that response nor accepts it as a @@ -113,22 +113,22 @@ configuration. Join links should be treated as recruitment links, not as harmles An intervention occurrence is keyed by a deterministic SHA-256 identity over its signed logical schedule position. A random-window trigger selects its instant locally with a CSPRNG and persists -the occurrence before scheduling; retry/reboot do not redraw it, time-zone reconciliation only +the occurrence before scheduling. Retry and reboot do not redraw it, time-zone reconciliation only plans future local dates, and there is no server trigger. Durable state distinguishes scheduled, notification posted, opened, submitted, and expired. Survey submission validates stable question and option IDs, then uses an encrypted transaction journal to commit one event and terminal metadata together. These controls prevent duplicate commits and partial durable answers; they do not prove that a participant saw a notification or personally supplied an answer. -The configuration admits at most 512 lifetime occurrences. This is a security and reliability bound, not an authoring suggestion: retaining every terminal identity is what prevents an old logical firing from reappearing after recovery, and the bound keeps that set under the authenticated metadata limit instead of silently weakening idempotency. +The configuration admits at most 512 lifetime occurrences. This is a security and reliability bound, not an authoring suggestion. Retaining every terminal identity is what prevents an old logical firing from reappearing after recovery, and the bound keeps that set under the authenticated metadata limit instead of silently weakening idempotency. ### Data leaving the device Study data leaves the device two ways, and both carry the same encrypted bundle. -The first is export: the participant picks a Storage Access Framework destination and the app writes the bundle there. The second is upload, which exists only if the signed configuration carries a populated `upload` block naming an `https://` endpoint, an interval, and whether metered networks are allowed. The app then posts bundles to that endpoint and to no other. A study whose `upload` block is empty transmits nothing. Which of the two applies is fixed by the configuration the participant consented to, and the endpoint host, the cadence, and the network condition are rendered into the consent step from the signed bytes rather than from the researcher's free-text summary — as a block the app asserts itself, directly below that summary and on the same panel as it. The manifest declares `android.permission.INTERNET`, so a build's permission list no longer distinguishes an uploading study from a non-uploading one. The signed configuration is what a reviewer should read, and the checks at the end of this document cover both. +The first is export: the participant picks a Storage Access Framework destination and the app writes the bundle there. The second is upload, which exists only if the signed configuration carries a populated `upload` block naming an `https://` endpoint, an interval, and whether metered networks are allowed. The app then posts bundles to that endpoint and to no other. A study whose `upload` block is empty transmits nothing. Which of the two applies is fixed by the configuration the participant consented to. The endpoint host, the cadence, and the network condition are rendered into the consent step from the signed bytes rather than from the researcher's free-text summary. The app asserts that block itself, directly below the summary and on the same panel as it. The manifest declares `android.permission.INTERNET`, so a build's permission list no longer distinguishes an uploading study from a non-uploading one. The signed configuration is what a reviewer should read, and the checks at the end of this document cover both. -Each bundle has a fresh AES-256 content key and nonce. RFC 9180 base-mode X25519/HKDF-SHA256/AES-256-GCM HPKE wraps that key to the raw researcher public key in the signed configuration; the wire value is fixed and contains no Tink keyset or prefix. Exact JCS context binds the bundle format, bundle UUID, full configuration SHA-256, and researcher key ID into HPKE and document AEAD. The authenticated document embeds the exact configuration, its original Ed25519 signature, producer platform/build, and exact range/count. The app never holds the researcher private key. +Each bundle carries a fresh content key that is sealed to the researcher public key in the signed configuration, so only the matching private key opens it. The app never holds that private key. The authenticated document embeds the exact configuration and its original Ed25519 signature, so a decrypted bundle carries the study it came from. The [Protocol v1 contract](../protocol/v1/README.md) specifies the HPKE suite, the `PTCEXP01` container, and the context bytes that bind both cryptographic layers to the bundle, the configuration digest, and the researcher key ID. A participant-directed export streams ciphertext to the chosen destination. Automatic upload is different by design: before HTTP, the app durably stages one complete ciphertext bundle and a @@ -137,7 +137,7 @@ and every retry after process death, reboot, timeout, or response loss sends exa The manifest contains only bounded bundle/receipt bookkeeping and an optional terminal code—no participant, assigned, experiment, or configuration ID—and is never placed in an HTTP request. -Decryption on the researcher's machine streams too, because a bundle is bounded by the study's storage quota rather than by a fixed size. One detail is deliberate and worth a reviewer's attention: `ResearchExport.decrypt` drives the AES-GCM cipher directly rather than reading through `CipherInputStream`, which reports an authentication failure as an ordinary end of stream. Through `CipherInputStream` a tampered bundle would decrypt into a silently truncated file that looks like a short study; driving the cipher directly keeps a bad tag an exception. Plaintext reaches only a mode-`0600` staging file before the tag is verified. The CLI then streams staging through `ResearchBundleVerifier`, which rechecks canonical bytes, embedded configuration and signature, identities, ranges, transition history, and catalog event contracts. It atomically publishes the file only when both phases pass and deletes staging on any failure. Anything else consuming the lower-level decrypt API inherits the same obligation: authenticate and validate the complete document before publishing plaintext. +Decryption on the researcher's machine streams too, because a bundle is bounded by the study's storage quota rather than by a fixed size. One detail is deliberate and worth a reviewer's attention. `ResearchExport.decrypt` drives the AES-GCM cipher directly rather than reading through `CipherInputStream`, which reports an authentication failure as an ordinary end of stream. Through `CipherInputStream` a tampered bundle would decrypt into a silently truncated file that looks like a short study; driving the cipher directly keeps a bad tag an exception. Plaintext reaches only a mode-`0600` staging file before the tag is verified. The CLI then streams staging through `ResearchBundleVerifier`, which rechecks canonical bytes, embedded configuration and signature, identities, ranges, transition history, and catalog event contracts. It atomically publishes the file only when both phases pass and deletes staging on any failure. Anything else consuming the lower-level decrypt API inherits the same obligation: authenticate and validate the complete document before publishing plaintext. What an upload endpoint therefore sees: @@ -151,25 +151,25 @@ All clear headers are untrusted routing claims. Receiver ingestion identity is t UUID plus exact bytes and metadata, never a participant/range pair. The participant UUID and any assigned code become linkable only after authorized decryption and remain personal data there. -Transport is TLS: the endpoint must be `https://`, validated when the configuration is decoded, and `usesCleartextTraffic="false"` remains set, so a plaintext HTTP endpoint cannot be configured or reached. There is **no certificate pinning**. The connection trusts the device's system trust store, so an attacker holding a certificate that store accepts — an enterprise or otherwise installed CA, for example — can see the delivery metadata above and can substitute their own endpoint. They still cannot read a bundle. +Transport is TLS. The endpoint must be `https://`, validated when the configuration is decoded, and `usesCleartextTraffic="false"` remains set, so a plaintext HTTP endpoint cannot be configured or reached. There is **no certificate pinning**. The connection trusts the device's system trust store. An attacker holding a certificate that store accepts — an enterprise or otherwise installed CA, for example — can see the delivery metadata above and can substitute their own endpoint. They still cannot read a bundle. -Delivery is durable rather than best-effort. A new durable object returns `201 Created`; an exact replay returns `200 OK` with the original canonical seven-field receipt. The client requires its bundle ID, byte count, configuration digest, event count, exact first/last sequence, and SHA-256 to match the outbox manifest before it advances `uploadedThroughSequence`. Redirects, `202`, other `2xx`, malformed receipts, and mismatches do not commit. Only I/O, `408`, `425`, `429`, and `5xx` retry; other protocol and `4xx` failures are terminal for that staged bundle but do not stop collection. The participant can still export by hand. +Delivery is durable rather than best-effort. The client advances `uploadedThroughSequence` only when the endpoint returns a receipt whose every field matches the outbox manifest for that staged bundle, so an ambiguous, malformed, or mismatched response commits nothing. A terminal delivery failure ends that bundle's attempts but does not stop collection, and the participant can still export by hand. The [Protocol v1 contract](../protocol/v1/README.md) defines the status codes, the receipt fields, and which failures retry. -A failed delivery is reported to the participant as a fixed reason code — `UPLOAD_TIMEOUT`, `UPLOAD_TLS_FAILED`, `UPLOAD_HTTP_`, `UPLOAD_IO_FAILED` and a few others. `StudyUploadException` carries the code instead of a message and validates it against the same `[A-Z][A-Z0-9_]{2,63}` pattern a collector's health reason uses, so a string that reaches the screen or the log cannot carry study data, an endpoint's response body, or a URL. The underlying transport exception is written to the Android log, where it is a network error rather than research data. Collector health failures surface their reason code the same way. +A failed delivery is reported to the participant as a fixed reason code — `UPLOAD_TIMEOUT`, `UPLOAD_TLS_FAILED`, `UPLOAD_HTTP_`, `UPLOAD_IO_FAILED` and a few others. `StudyUploadException` carries the code instead of a message, and validates it against the same `[A-Z][A-Z0-9_]{2,63}` pattern a collector's health reason uses. A string that reaches the screen or the log therefore cannot carry study data, an endpoint's response body, or a URL. The underlying transport exception is written to the Android log, where it is a network error rather than research data. Collector health failures surface their reason code the same way. -Confirmed delivery is also what makes local data reclaimable. Once a study's storage passes 80% of its configured quota, a successful upload lets the device release whole leading segments — down to 60% — provided every event in them was confirmed by the endpoint and they are not the segment still being written. `StudyMetadata.retainedFromSequence` records the lowest sequence still present, and the participant's dashboard states how many earlier events were delivered and removed. Below that threshold a study keeps everything, and an endpoint that never confirms anything reclaims nothing. +Confirmed delivery is also what makes local data reclaimable. Once a study's storage passes 80% of its configured quota, a successful upload lets the device release whole leading segments, down to 60%. A segment is eligible only if the endpoint confirmed every event in it, and never if it is the segment still being written. `StudyMetadata.retainedFromSequence` records the lowest sequence still present, and the participant's dashboard states how many earlier events were delivered and removed. Below that threshold a study keeps everything, and an endpoint that never confirms anything reclaims nothing. ### State boundaries and failure behaviour -Entering `RUNNING` mints an admission epoch token that collectors must present, and events carry their original observation time. On pause, completion, or withdrawal the runtime takes a monotonic boundary, persists the transition first, then drains: only events from the same epoch observed strictly before the boundary are still admitted, after which the epoch closes permanently. A delayed callback cannot smuggle post-pause data into the dataset. +Entering `RUNNING` mints an admission epoch token that collectors must present, and events carry their original observation time. On pause, completion, or withdrawal the runtime takes a monotonic boundary, persists the transition first, then drains. Only events from the same epoch observed strictly before the boundary are still admitted, after which the epoch closes permanently. A delayed callback cannot smuggle post-pause data into the dataset. -A storage write failure or exhausted quota force-closes the admission gate, records an incident code, and fail-closes the study to `PAUSED`. There is no ring buffer and no silent dropping of events, and reclaiming space is a different thing from either: it can only release events an endpoint has already confirmed receiving, so a quota that fills with nothing delivered stops the study rather than making room, and what was released is recorded in `retainedFromSequence`, declared in every bundle's `first_sequence_number`, and stated on the participant's dashboard. Nothing that has not reached the research team is ever discarded to free space. +A storage write failure or exhausted quota force-closes the admission gate, records an incident code, and fail-closes the study to `PAUSED`. There is no ring buffer and no silent dropping of events. Reclaiming space is a different thing from either: it can only release events an endpoint has already confirmed receiving, so a quota that fills with nothing delivered stops the study rather than making room. What was released is recorded in `retainedFromSequence`, declared in every bundle's `first_sequence_number`, and stated on the participant's dashboard. Nothing that has not reached the research team is ever discarded to free space. -Occurrence lifecycle events and survey submissions have a stronger two-record boundary: the encrypted `PTCTXN01` journal makes the event append and updated metadata recoverable as one idempotent commit. Recovery applies a one-boundary-ahead journal only after authenticating an exactly matching durable tail event; it discards a prepared journal whose event is absent, and treats a same-boundary journal as a stale leftover with main metadata authoritative. Any other boundary or content mismatch fails closed. No unverified fallback reconstructs a response from UI state. +Occurrence lifecycle events and survey submissions have a stronger two-record boundary: the encrypted `PTCTXN01` journal makes the event append and updated metadata recoverable as one idempotent commit. Recovery applies a one-boundary-ahead journal only after authenticating an exactly matching durable tail event. It discards a prepared journal whose event is absent, and treats a same-boundary journal as a stale leftover with main metadata authoritative. Any other boundary or content mismatch fails closed. No unverified fallback reconstructs a response from UI state. A corrupt segment, index gap, AEAD failure, or missing key is a hard failure, and only an incomplete trailing frame in the final segment may be recovered. Event segments missing *below* the retained floor are a hard failure too, because a prefix that disappeared without being reclaimed is indistinguishable from one that was tampered away. Main metadata must name the durable tail unless the authenticated one-event journal proves the single permitted append-recovery state; no durable count is guessed or rebuilt as fallback. An export that cannot read its whole window to the boundary fails rather than producing a partial file. Missing required access keeps a study from reaching `READY`, and a foreground service that fails to start rolls the runtime back instead of collecting. A dataset is therefore either complete over the window it declares or absent, rather than quietly partial. -**When an event payload is authenticated, and when it is not.** Normal study opening decrypts no events. The sequence number is stored unencrypted at the front of each frame, so framing, segment index, and contiguity are checked from plaintext headers, while metadata — including each collector's last event — has its own AES-GCM tag. The only exception is the unique crash state where a one-boundary-ahead journal and a complete event tail are both durable: opening then decrypts and authenticates exactly that tail before applying the journal. This keeps open cost linear in frames with no per-event crypto; recovery adds at most one event decrypt. **An event payload's authentication tag is otherwise verified when that event is read.** Corruption or tampering inside another event body surfaces on export or upload as a hard failure at that point. Nothing is accepted unverified — a tampered event still cannot reach a bundle — but detection for non-recovery-tail events is deferred, so a device holding a damaged log can look healthy until its data is next read. +**When an event payload is authenticated, and when it is not.** Normal study opening decrypts no events. The sequence number is stored unencrypted at the front of each frame, so framing, segment index, and contiguity are checked from plaintext headers. Metadata — including each collector's last event — has its own AES-GCM tag. The only exception is the unique crash state where a one-boundary-ahead journal and a complete event tail are both durable. Opening then decrypts and authenticates exactly that tail before applying the journal. This keeps open cost linear in frames with no per-event crypto; recovery adds at most one event decrypt. **An event payload's authentication tag is otherwise verified when that event is read.** Corruption or tampering inside another event body surfaces on export or upload as a hard failure at that point. Nothing is accepted unverified, and a tampered event still cannot reach a bundle. But detection for non-recovery-tail events is deferred, so a device holding a damaged log can look healthy until its data is next read. ### Deletion @@ -183,13 +183,13 @@ These are known limitations rather than bugs. Reports about them are handled as **An unlocked device in someone else's hands.** There is no in-app authentication, PIN, or biometric gate. Anyone holding the unlocked phone can open the app, export the bundle to any destination, or delete the local data. At-rest encryption covers the seized-and-powered-off case, not this one. -**A malicious or careless researcher.** The researcher writes the configuration, chooses the collectors and their rates, writes the consent text, and holds the private key that decrypts everything participants send. The software constrains what is technically possible — only the compiled-in collectors, within validated parameter ranges, after the participant grants each Android permission — but it cannot verify that the consent text honestly describes any of it. A participant who consents to a study is trusting that research team, not this software. Ethics review is the control here, and the platform supports it: the configuration is human-readable, signed, and reproduced verbatim inside every export. +**A malicious or careless researcher.** The researcher writes the configuration, chooses the collectors and their rates, writes the consent text, and holds the private key that decrypts everything participants send. The software constrains what is technically possible: only the compiled-in collectors, within validated parameter ranges, after the participant grants each Android permission. It cannot verify that the consent text honestly describes any of it. A participant who consents to a study is trusting that research team, not this software. Ethics review is the control here, and the platform supports it: the configuration is human-readable, signed, and reproduced verbatim inside every export. One narrow part of this is closed by the data step described under *Scope of collection*. Which collectors are enabled, at what rate, and what each cannot see are stated by the app from the signed parameters, so those particular claims cannot be softened in the telling. Everything around them — why the data is collected, who reaches it, how long it is kept, what withdrawal means on the research side — is still the researcher's prose, and nothing in the app checks it. **Publisher impersonation.** The researcher name and contact shown on the consent screen come from the signed configuration, which makes them text the signer chose. Anyone can generate an Ed25519 key, write a configuration naming any research team, sign it, and produce a file that verifies on a build with no pinned signers. The signature is genuine; what it certifies is the file, not its author. -Three things narrow this. The consent step shows the key fingerprint — SHA-256 over the raw 32-byte public key, first 16 bytes, as eight uppercase groups of four hex characters — under the heading *Configuration signature*, and, when the signer is not pinned, asks the participant to check that fingerprint against the one their research team published, noting underneath that a signature shows a file is unaltered rather than who wrote it. Deliberately none of it is in the error colour. An unpinned signer is the deployment model, not a failure, and rendering the ordinary case as an alarm trains a reader to skip the block — the mitigation here depends on the participant actually performing a comparison, so the text is an instruction rather than a warning. That wording is in the app's string resources rather than in Kotlin, so it is translated with the rest of the interface and no configuration can alter it. A team that publishes its fingerprint through the channel that recruited its participants gives them a check that copied prose does not defeat. And a configuration is not an anonymous download: it reaches a participant through a relationship that already exists, so an impersonator has to get their file in front of someone through that channel. +Three things narrow this. The consent step shows the key fingerprint under the heading *Configuration signature*. That fingerprint is the first 16 bytes of SHA-256 over the raw 32-byte Ed25519 public key, rendered as eight uppercase groups of four hexadecimal characters; a join link pins the same value without the spaces. When the signer is not pinned, the step asks the participant to check that fingerprint against the one their research team published, noting underneath that a signature shows a file is unaltered rather than who wrote it. Deliberately none of it is in the error colour. An unpinned signer is the deployment model, not a failure, and rendering the ordinary case as an alarm trains a reader to skip the block. The mitigation here depends on the participant actually performing a comparison, so the text is an instruction rather than a warning. That wording is in the app's string resources rather than in Kotlin, so it is translated with the rest of the interface and no configuration can alter it. A team that publishes its fingerprint through the channel that recruited its participants gives them a check that copied prose does not defeat. And a configuration is not an anonymous download: it reaches a participant through a relationship that already exists, so an impersonator has to get their file in front of someone through that channel. A build that pins its signers removes this exposure for the studies it accepts, and accepts nothing else. See the [researcher guide](researcher-guide.md) for both sides of that choice. @@ -199,11 +199,11 @@ A build that pins its signers removes this exposure for the studies it accepts, - The bundle header exposes the researcher key ID in cleartext, and the suggested export filename contains the study ID and an export timestamp. Anyone handling the file can tell that this person participated in that study. - Size discloses roughly how much data was collected. The same is true on the device: event segment file sizes and modification times leak collection volume and timing to anyone with filesystem read access, without any decryption. -**What a notification discloses to someone holding the phone.** Three kinds of notification are visible without any decryption. The ongoing collection notification is present only while collection is actually running, and it carries the study title on its second line. An intervention notification is posted only while the study is running, one per occurrence, and its title and message are the researcher's own text out of the signed configuration, so a survey prompt is on the screen in whatever words the researcher chose; its channel is `IMPORTANCE_DEFAULT`, so unlike the other two it alerts. The third is the daily status reminder: once a study has started, `DailyStatusWorker` posts one notification a day for as long as the study is either collecting or paused, and it is the only notification the app shows while a study is paused, because pausing stops the foreground service and cancels every intervention notification. Its title line is the application's own name; its body says either that collection continues or that the study is paused and since when. It carries no study title, no researcher name or contact, no counts, and no collector names, so a bystander learns that this phone runs Particeps and which of those two states it is in — not which study, not what that study records, and not who is running it. The channel is `IMPORTANCE_LOW`, so the reminder is silent; one notification tag is reused, so today's replaces yesterday's rather than accumulating; and if the participant never granted the notification permission, nothing is posted at all. The app sets no lockscreen visibility on either the channel or the notification, so the device's own setting for notification content on a locked screen is what decides whether the text can be read without unlocking. +**What a notification discloses to someone holding the phone.** Three kinds of notification are visible without any decryption. The ongoing collection notification is present only while collection is actually running, and it carries the study title on its second line. An intervention notification is posted only while the study is running, one per occurrence. Its title and message are the researcher's own text out of the signed configuration, so a survey prompt is on the screen in whatever words the researcher chose. Its channel is `IMPORTANCE_DEFAULT`, so unlike the other two it alerts. The third is the daily status reminder. Once a study has started, `DailyStatusWorker` posts one notification a day for as long as the study is either collecting or paused. It is the only notification the app shows while a study is paused, because pausing stops the foreground service and cancels every intervention notification. Its title line is the application's own name; its body says either that collection continues or that the study is paused and since when. It carries no study title, no researcher name or contact, no counts, and no collector names. A bystander therefore learns that this phone runs Particeps and which of those two states it is in — not which study, not what that study records, and not who is running it. The channel is `IMPORTANCE_LOW`, so the reminder is silent. One notification tag is reused, so today's replaces yesterday's rather than accumulating. If the participant never granted the notification permission, nothing is posted at all. The app sets no lockscreen visibility on either the channel or the notification, so the device's own setting for notification content on a locked screen is what decides whether the text can be read without unlocking. -The residual risk is that the existence and the duration of a study become visible to whoever holds the phone. That is the cost of the reminder rather than a defect in it — a pause that nothing mentions is how a study meant to run for a fortnight quietly records nothing — but it is a standing daily disclosure for the study's whole length, and it is the one surface that goes on disclosing after a participant has paused. Starting or stopping collection retracts a reminder that is already showing, and finishing or withdrawing cancels both the schedule and the notification, so none of it outlives the study. Android's per-channel notification settings let a participant turn the reminder off; that removes the reminder, not the study. +The residual risk is that the existence and the duration of a study become visible to whoever holds the phone. That is the cost of the reminder rather than a defect in it: a pause that nothing mentions is how a study meant to run for a fortnight quietly records nothing. But it is a standing daily disclosure for the study's whole length, and it is the one surface that goes on disclosing after a participant has paused. Starting or stopping collection retracts a reminder that is already showing, and finishing or withdrawing cancels both the schedule and the notification, so none of it outlives the study. Android's per-channel notification settings let a participant turn the reminder off; that removes the reminder, not the study. -**A compromised or hostile upload endpoint.** An endpoint that is taken over, misconfigured, or logging more than intended still cannot read a bundle without the researcher private key. It does learn the untrusted bundle metadata above, including a stable configuration digest that can link submissions from the same issued artifact. It can refuse delivery indefinitely; the device retains the data and collection continues. Conversely, an endpoint can fabricate a matching seven-field `201`/`200` receipt without keeping the body. That can advance the watermark and eventually make those events reclaimable under storage pressure. Receipt matching makes response loss and accidental mismatch safe; it cannot prove remote durability against the server itself. Treat the endpoint as study infrastructure, keep the decryption key off it, minimize logs, and state its operator in consent material. +**A compromised or hostile upload endpoint.** An endpoint that is taken over, misconfigured, or logging more than intended still cannot read a bundle without the researcher private key. It does learn the untrusted bundle metadata above, including a stable configuration digest that can link submissions from the same issued artifact. It can refuse delivery indefinitely; the device retains the data and collection continues. Conversely, an endpoint can fabricate a matching receipt without keeping the body. That can advance the watermark and eventually make those events reclaimable under storage pressure. Receipt matching makes response loss and accidental mismatch safe; it cannot prove remote durability against the server itself. Treat the endpoint as study infrastructure, keep the decryption key off it, minimize logs, and state its operator in consent material. The receiver ingress has no participant authentication or device attestation. An attacker can submit bounded bogus ciphertext and consume storage. Deployment-time configuration-digest/key allowlists, @@ -222,7 +222,7 @@ cannot see text, but its timing and within-key position data are behaviourally d **Configuration replay and clock manipulation.** The signed envelope has no nonce and no device binding, so the same configuration can be imported on any number of devices until it expires. Validity is checked against the device wall clock, so a participant who moves their clock backwards can revive an expired configuration. Keep validity windows short; a multi-year window makes both worse. -**No signer revocation.** There is no revocation list, no in-protocol rotation, and no kill switch at any layer. A leaked study signing key can mint configurations that any build with an empty anchor map accepts, and configurations already signed with it stay valid until they expire; a short validity window is the only control. Rotating a study signing key is possible but entirely manual: the researcher generates a new key, puts it in the `signer` block of a new configuration, re-signs, and republishes the fingerprint through the channel that recruited the participants, as the [researcher guide](researcher-guide.md) sets out. No app release is involved, and nothing on a device learns that the old key was retired, so a rotation governs configurations signed after it and nothing already issued. Where a build does pin signers, that set is fixed and auditable at build time, and retiring one of those keys requires shipping a new APK. +**No signer revocation.** There is no revocation list, no in-protocol rotation, and no kill switch at any layer. A leaked study signing key can mint configurations that any build with an empty anchor map accepts, and configurations already signed with it stay valid until they expire. A short validity window is the only control. Rotating a study signing key is possible but entirely manual. The researcher generates a new key, puts it in the `signer` block of a new configuration, re-signs, and republishes the fingerprint through the channel that recruited the participants, as the [researcher guide](researcher-guide.md) sets out. No app release is involved, and nothing on a device learns that the old key was retired, so a rotation governs configurations signed after it and nothing already issued. Where a build does pin signers, that set is fixed and auditable at build time, and retiring one of those keys requires shipping a new APK. **Key loss.** Losing the researcher HPKE private key makes every export for that configuration permanently unreadable. There is no escrow and Protocol v1 names exactly one raw recipient key, so multi-recipient encryption is unavailable. Losing the device's Keystore key — through device wipe, uninstall, or clearing app data — destroys all un-exported local data. Neither case has a recovery path. @@ -230,17 +230,17 @@ cannot see text, but its timing and within-key position data are behaviourally d ## Deployment requirements -The shipped build pins no signers. It accepts any correctly signed configuration and tells the participant that the publisher is unverified. That is the deployment model rather than an outstanding task, and it retires a blocker that earlier releases carried: the default build no longer compiles in the demonstration signer as its only trust anchor, so it no longer trusts a key whose private half is published in this repository. The demo signer now has no standing that any other signer lacks. +The shipped build pins no signers. It accepts any correctly signed configuration and tells the participant that the publisher is unverified. That is the deployment model rather than an outstanding task. The default build compiles in no trust anchor at all, so it does not privilege the demonstration signer, whose private half is published in this repository. That signer has no standing that any other signer lacks. -The demonstration keys are still public. **The demonstration signing private key is published**, so anyone can sign a configuration that presents itself as the demo study. **The demonstration HPKE private key is published too**, so exports produced under the demo study are readable by anyone who clones the repository. Both fixture files carry an `INSECURE-` prefix for this reason; see [`researcher-tools/examples/README.md`](../researcher-tools/examples/README.md). +The demonstration keys remain public and the release variant ships no demonstration study, so a participant who installs a release cannot start a study whose export key is published in this repository. See [`researcher-tools/examples/README.md`](../researcher-tools/examples/README.md) for both. -What follows from that is a build-variant boundary rather than a trust decision: **the release variant ships no demonstration study at all.** The signed envelope (`res/raw/demo_study_envelope.txt`) and the code that reads it are in the app's `debug` source set, so neither is compiled or packaged into a release APK, and the dashboard renders no entry point for it. A participant who installs a release therefore cannot start a study whose export key is public. The demo remains available in debug builds, which is what the instrumentation test exercises. Note what this boundary does not claim: it removes a foot-gun, not an attack. Anyone can still sign their own configuration with the published demo key and hand the file to someone, because pinning no signers is the deployment model — the consent screen is what carries that, by reporting the publisher as unverified and showing the fingerprint. +Note what that boundary does not claim: it removes a foot-gun, not an attack. Anyone can still sign their own configuration with the published demo key and hand the file to someone, because pinning no signers is the deployment model. The consent screen is what carries that, by reporting the publisher as unverified and showing the fingerprint. -What a real deployment owes participants is its own key pairs and a published signing key fingerprint, distributed through the channel that recruits them, so the fingerprint on the consent screen can be compared against something. An institution that wants one build to run only its own studies pins its key in `CollectorApplication.TRUSTED_SIGNING_KEYS` and ships that build, which then refuses every other signer. +What a real deployment owes participants is its own key pairs and a published signing key fingerprint. Distributing that fingerprint through the channel that recruits participants is what gives the consent screen something to be compared against. An institution that wants one build to run only its own studies pins its key in `CollectorApplication.TRUSTED_SIGNING_KEYS` and ships that build, which then refuses every other signer. ## Supply chain -The dependency set is small: Tink for cryptography, Gson for parsing, AndroidX Compose, Lifecycle and WorkManager, Google Play Services Location for the fused location provider, and OkHttp 5.3.0 for the upload request. There is no analytics, crash reporter, or telemetry library. OkHttp is used from one class, `OkHttpStudyUploader`, which builds a single POST to the endpoint the signed configuration names; Play Services Location is the only other dependency with a plausible network surface. +The dependency set is small: Tink for cryptography, Gson for parsing, AndroidX Compose, Lifecycle and WorkManager, Google Play Services Location for the fused location provider, and OkHttp 5.3.0 for the upload request. There is no analytics, crash reporter, or telemetry library. OkHttp is used from one class, `OkHttpStudyUploader`, which builds a single POST to the endpoint the signed configuration names. Play Services Location is the only other dependency with a plausible network surface. Gradle and package lockfiles make the selected dependency graph reviewable. Vulnerability and secret-scan results are point-in-time observations rather than a continuing guarantee, so re-run diff --git a/particeps-analysis/README.md b/particeps-analysis/README.md index 7fd7677..320e92d 100644 --- a/particeps-analysis/README.md +++ b/particeps-analysis/README.md @@ -37,14 +37,13 @@ LocalBundleSource / S3BundleSource R2 metadata and paths are untrusted routing claims. Participant/event identity exists only after the complete authenticated document verifies. Automatic receiver objects retain the 32 MiB wire -bound. A local manual export may reach its signed local-storage quota (at most 8 GiB), so GCM -decryption, JCS checking, event validation, reassembly, and Parquet row groups are streamed or -spilled instead of loading the document into memory. AEAD authentication completes before JSON is -accepted. Every plaintext staging/spill artifact is inside a tightened mode-0700 directory with -owner-only files and is removed on every handled success or failure. One invalid bundle is -quarantined whole and -emits no rows. A conflicting authenticated event identity stops dataset publication; there is no -last-write-wins behavior or unknown-schema fallback. +bound. A local manual export may reach its signed local-storage quota, at most 8 GiB. GCM +decryption, JCS checking, event validation, reassembly, and Parquet row groups are therefore +streamed or spilled instead of loading the document into memory. AEAD authentication completes +before JSON is accepted. Every plaintext staging/spill artifact is inside a tightened mode-0700 +directory with owner-only files and is removed on every handled success or failure. One invalid +bundle is quarantined whole and emits no rows. A conflicting authenticated event identity stops +dataset publication; there is no last-write-wins behavior or unknown-schema fallback. ## Install and run @@ -109,9 +108,9 @@ uv run python -m unittest discover -s tests -v list/decrypt/admin API. - `reports/validation-report.json` records quarantine and conflict outcomes even when publication stops. `quality-summary.json` distinguishes overlaps, identical duplicates, conflicts, interior - gaps, undelivered suffixes, reclaimed prefixes, and achieved mean sampling rates (rounded to the - nearest millihertz, with the exact interval count and duration retained); it does not infer - participant behavior. Sensor rates use the catalog-declared hardware/source + gaps, undelivered suffixes, reclaimed prefixes, and achieved mean sampling rates. Rates are + rounded to the nearest millihertz, and the exact interval count and duration are retained. It + does not infer participant behavior. Sensor rates use the catalog-declared hardware/source `source_elapsed_realtime_nanos`, not callback-envelope time, so Android FIFO batching does not collapse the measured duration. - Potentially large quality collections use the stable @@ -121,9 +120,9 @@ uv run python -m unittest discover -s tests -v `examples_truncated` is true. - An identical duplicate has the same authenticated event identity and bytes. A conflict has the same identity but different bytes and stops publication. An interior gap is absent below the - highest arrived sequence; an undelivered suffix is absent after it but at or below the latest - authenticated durable boundary; a reclaimed prefix is absent below the latest authenticated - retained boundary. These labels describe evidence availability, not why a participant did or did - not produce an observation. + highest arrived sequence. An undelivered suffix is absent after that sequence but at or below the + latest authenticated durable boundary. A reclaimed prefix is absent below the latest + authenticated retained boundary. These labels describe evidence availability, not why a + participant did or did not produce an observation. - Database connectors are intentionally out of scope. `DatasetSink` is the narrow extension contract; Parquet is the only implementation in this release. diff --git a/protocol/v1/README.md b/protocol/v1/README.md index 9c29289..9e0ab28 100644 --- a/protocol/v1/README.md +++ b/protocol/v1/README.md @@ -15,9 +15,10 @@ implementation are invalid, as they always were. Artifacts bearing the retired A Collector identity — `ADCCFG01`, `ADCEXP01`, `research-bundle-v1`, `adc://join/v1`, `application/vnd.adc.research-bundle`, or any `X-ADC-*` header — are invalid too. Neither class is an earlier dialect of this protocol. Readers MUST NOT retain a parser, migration path, dual -interpretation, alias, sniffing heuristic, or fallback for either, and MUST fail closed on them +interpretation, alias, sniffing heuristic, or fallback for either. They MUST fail closed on both exactly as on random bytes. The hostile corpus in this directory carries executable coverage for -both. +both. [CHANGELOG.md](../../CHANGELOG.md) records which release retired which spelling, and what +that asks of someone who already installed one. The companion [`collector-catalog.json`](collector-catalog.json) is the closed-world collector and event schema. [`conformance-vectors.json`](conformance-vectors.json) and @@ -76,7 +77,9 @@ offset size value signature. The signed message is exactly `configuration_jcs`, without a framing prefix. The key ID must equal `configuration.signer.key_id`. The verifier obtains the raw Ed25519 public key from `configuration.signer.public_key`, verifies the fixed 64-byte signature, then applies signer -pinning policy. A valid self-contained signature proves integrity, not publisher identity. +pinning policy. A valid self-contained signature proves integrity, not publisher identity. What +that trust model is and is not worth — pinning, fingerprint comparison, and publisher +impersonation — is in the [threat model](../../docs/threat-model.md). The configuration SHA-256 used everywhere below is SHA-256 over `configuration_jcs`, not over the envelope. @@ -107,16 +110,16 @@ decoded artifact URL uses this deliberately narrow canonical HTTPS profile: This profile is intentionally sufficient for an immutable filename or opaque path token, not a general browser URL. A personalized artifact MUST use at least 128 bits of random opaque path -material (the authoring tools require a final base64url segment of at least 22 characters), and -MUST NOT put an assigned participant ID in the URL or link. +material; the authoring tools require a final base64url segment of at least 22 characters. It MUST +NOT put an assigned participant ID in the URL or link. The Android app rejects a join while any active study or pending deletion exists. It performs one -bounded GET with redirects and implicit retries disabled, stages under no-backup storage, checks -the complete artifact SHA-256, then executes the ordinary Ed25519 verification and fingerprint / -consent flow. The host cannot replace accepted bytes: digest mismatch fails before signature -verification. Staging is cleared on process startup, before each attempt, and after success or -failure. There is no polling, refresh, replacement, background update, or assigned participant ID -in the join URI. +bounded GET with redirects and implicit retries disabled, and stages the response under no-backup +storage. It then checks the complete artifact SHA-256 and executes the ordinary Ed25519 +verification and fingerprint / consent flow. The host cannot replace accepted bytes: digest +mismatch fails before signature verification. Staging is cleared on process startup, before each +attempt, and after success or failure. There is no polling, refresh, replacement, background +update, or assigned participant ID in the join URI. ## Encrypted bundle (`PTCEXP01`) @@ -149,7 +152,7 @@ offset size value `researcher_key_id_length` is in `3..64`; the decoded value matches `[a-z0-9][a-z0-9-]{2,63}`. `C` is greater than the 16-byte GCM tag. An automatic-upload container is -at most 32 MiB; a manual export has no 32 MiB wire limit and is instead bounded by the signed local +at most 32 MiB. A manual export has no 32 MiB wire limit and is instead bounded by the signed local storage quota, so manual-export readers stream it. There is no ciphertext-length field: the file or HTTP body ends the container, and truncation or appended bytes fail authentication or JCS validation. @@ -163,16 +166,15 @@ context = UTF8({"bundle_format":"particeps-research-bundle-v1","bundle_id":" why the retired spelling belongs there. Nothing else may carry one. ALLOWED: dict[str, str] = { - "README.md": "documents that pre-rename artifacts and installs are unsupported", + "CHANGELOG.md": "the release history; naming the identity each release carried is its job", "app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt": "retired-identity rejection fixture: import must fail closed on the old magic", - "docs/maintainers/release.md": "records the cutover a maintainer still has to finish", - "docs/p0-p2-implementation-contract.md": "invariant naming exactly which inputs are rejected", "docs/participant-guide.md": "tells a participant what the app they already installed was called", "docs/researcher-guide.md": "states that a pre-rename configuration or export is refused", "protocol/v1/README.md": "normative statement of the retired identity's rejection", diff --git a/web/CONTRACT.md b/web/CONTRACT.md index 2e7481f..8882444 100644 --- a/web/CONTRACT.md +++ b/web/CONTRACT.md @@ -45,10 +45,10 @@ Configuration-specific Protocol v1 changes are: - both keys use canonical unpadded base64url; - `location.v1.minimum_displacement_millimeters` is an integer JSON number. -`parseConfiguration` first requires canonical, closed-world bytes, builds the typed value without -defaults, re-encodes it to prove Android normalization equality, applies all schema validation, and, -for `.partcfg`, verifies signer identity and Ed25519 signature. It never drops an unknown member or -repairs an old shape. +`parseConfiguration` first requires canonical, closed-world bytes. It builds the typed value +without defaults, re-encodes it to prove Android normalization equality, and applies all schema +validation. For `.partcfg` it then verifies signer identity and the Ed25519 signature. It never +drops an unknown member or repairs an old shape. ## Keys and signed configuration @@ -68,25 +68,9 @@ container must end after byte 64 of the signature. ## Encrypted bundle reader The browser reader is a bounded convenience reader; large-study analysis belongs in the offline -Python pipeline. `PTCEXP01` is exactly: - -```text -magic[8] | bundle UUID[16] | configuration SHA-256[32] | -researcher_key_id_length u16 BE | content nonce[12] | researcher_key_id UTF-8 | -RFC 9180 wrapped content key[80] | AES-256-GCM encrypted JCS document and tag -``` - -The wrapped key is raw RFC 9180 output (`enc[32] + ciphertext[48]`) with no Tink prefix. The HPKE -info and document AAD are the JCS bytes of: - -```json -{ - "bundle_format": "particeps-research-bundle-v1", - "bundle_id": "lowercase UUID", - "configuration_sha256": "64 lowercase hex", - "researcher_key_id": "key ID" -} -``` +Python pipeline. `bundle.ts` implements the `PTCEXP01` container, its HPKE layer, and the exact +context bytes that bind both cryptographic layers as +[`../protocol/v1/README.md`](../protocol/v1/README.md) specifies them. The authenticated document uses exact root objects for producer, signature provenance, and experiment state. Every sequence, counter, wall time, and monotonic time is a canonical decimal @@ -106,8 +90,8 @@ Private bytes stay in the tab and are never written to browser storage. ## Immutable join artifact -After an envelope exists, `JoinLinkPanel.svelte` accepts one artifact URL, delegates the exact -Protocol v1 URL / URI bytes to `join.ts`, and renders the resulting QR locally with the bundled +After an envelope exists, `JoinLinkPanel.svelte` accepts one artifact URL and delegates the exact +Protocol v1 URL / URI bytes to `join.ts`. It renders the resulting QR locally with the bundled `qrcode` library. There is no QR service, fetch, upload, polling, or browser persistence. The URI binds the envelope's complete SHA-256 and signing fingerprint; editing the study retires the envelope and therefore the join artifact.