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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,15 @@
"platform": "kotlin",
"rationale": "Swift Repository.refresh reads the same imported daily union (unionDailyMetrics) in the excluded app layer and hands it, still apart from the computed union, to ChargeBaselines.resolve (#2525); this disposition does not assert behavioral parity.",
"twin_path": "Strand/Data/Repository.swift"
},
{
"type": "out_of_scope_twin",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/data/DataBackup.kt::writeBackupZip/4#1",
"identity_sha256": "c6f74cc3acf36fb9f2695fb06f73513dbd9465c66543c5da266fbc7d6178fd94",
"platform": "kotlin",
"rationale": "Refs #2708: the Swift DataBackup.writeBackupZip(dbURL:to:settingsJSON:manifestJSON:) archive writer is in the excluded app layer. This records the container-writing role twin for the Android production stream extraction without asserting byte-identical ZIP compression.",
"twin_path": "Strand/Data/DataBackup.swift"
}
]
}
2 changes: 1 addition & 1 deletion Tools/parity_ledger_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
{"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 30, "identities_sha256": "a9dc115534e8710217db9cd39bcceaa1419741737bfd3b733007dc0215cd5af2"},
{"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"},
{"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 65, "identities_sha256": "fda58e5bb0fe07a9fa773d8e5f8961d483e8b177f1529b6e9dc3ac26df7c31cf"},
{"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 13, "identities_sha256": "6abaae19303cadccbe259d7db320850e269433472b28c84a1bfce7d32d54ce5f"},
{"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 12, "identities_sha256": "da661c1cb68d13a042bfc029410bd61858202bcd498a8bf16aa2b30c239bfe32"},
{"rule": "test-only-callsite", "scope": "android/ingest", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/ingest; no wildcard matching.", "count": 12, "identities_sha256": "d4f45d66087768eab133b918af6658b06ec1d91305e38fb045f6ae1629c94678"},
{"rule": "test-only-callsite", "scope": "android/oura", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/oura; no wildcard matching.", "count": 4, "identities_sha256": "9beaa9034af0a1021b9a9f43739971934f260147e0a9a5c462468548a7fd577d"},
{"rule": "test-only-callsite", "scope": "android/protocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/protocol; no wildcard matching.", "count": 31, "identities_sha256": "ecb83d46b48df8ad0bd63bb5b91125fe5be1fe35aa25bbd539c0dca998735226"}
Expand Down
4 changes: 2 additions & 2 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
},
"authority": {
"files": {"count": 510, "sha256": "b1c73338e79009d984d1313d8b3166e52672712c68074f5fee190c503e299b18"},
"functions": {"count": 4538, "sha256": "f62a1ca0dfb2122fdca87a2f455ec0ca4435903c24069ae13bc4e75c0c3f32c0"},
"functions": {"count": 4538, "sha256": "0aefe76deccfa1024cdff19bd307dea0d6f174f9aa5735411a55460828486dfc"},
"properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"},
"constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"},
"file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"},
"function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"},
"property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"},
"constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"},
"unpaired_files": {"count": 370, "sha256": "441451c8f68b68e1dc3d3556c28952b4bdac243de9d6f909d3c18d6ac9732a1e"},
"unpaired_functions": {"count": 4099, "sha256": "3ee3e74d901e17225e294c309c1b00a7a5b178bb3164bff43eaaa5309d574169"},
"unpaired_functions": {"count": 4099, "sha256": "6e7aa9720d92ac0c4e95bb6e3f47d01ff3151cbd630285d64107cd0037d94981"},
"unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"},
"unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"}
}
Expand Down
62 changes: 28 additions & 34 deletions android/app/src/main/java/com/noop/data/DataBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -294,24 +294,7 @@ object DataBackup {
// unaffected. Anything committed after the checkpoint above lives in the new WAL and
// is simply (consistently) absent from this snapshot, same as before.
db.runInTransaction {
ZipOutputStream(out).use { zip ->
zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME))
// #1807: count what actually LANDS in the entry, rather than reading dbFile.length()
// afterwards. The copy runs inside this transaction against a snapshot; once it
// commits, Room can checkpoint again and the main file's length can move, so a later
// read is a different number from the one the restore path will meet. The cap is
// enforced on this entry, so this is the only measurement that answers the question.
dbFile.inputStream().use { input -> entryBytes = input.copyTo(zip) }
zip.closeEntry()
if (settingsJson != null) {
zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME))
zip.write(settingsJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
zip.putNextEntry(ZipEntry(MANIFEST_ENTRY_NAME))
zip.write(manifestJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
entryBytes = writeBackupZip(dbFile, out, settingsJson, manifestJson)
}
}
// #1014 (write-side): the SOURCE was verified before archiving, but the PRODUCED file can still
Expand Down Expand Up @@ -675,24 +658,35 @@ object DataBackup {
}
}

/** Write [dbFile]'s bytes into a deflate ZIP at [dest] (the `.noopbak` container), DB entry first,
* plus the optional `settings.json` entry (#1000) when [settingsJson] is non-null. Context-free
* twin of the stream the live [exportTo] writes, so tests round-trip a real archive of either
* shape (legacy single-entry when [settingsJson] is null). */
/**
* Write the container used by [exportTo] and return the DB bytes copied into its first entry.
* The caller holds Room's write transaction while copying its checkpointed snapshot; measuring
* this copy keeps the restore-ceiling warning tied to the archived DB rather than a later file size.
* Optional settings and provenance follow the DB, preserving older importers' entry-order contract.
* Finishes and closes the ZIP before verification. The caller also scopes [output] with `use` so
* provider failures during ZIP finalization cannot prevent the underlying stream from closing.
*/
@Throws(IOException::class)
fun writeBackupZip(dbFile: File, dest: File, settingsJson: String? = null) {
FileOutputStream(dest).use { out ->
ZipOutputStream(out).use { zip ->
zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME))
dbFile.inputStream().use { input -> input.copyTo(zip) }
zip.closeEntry()
if (settingsJson != null) {
zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME))
zip.write(settingsJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
internal fun writeBackupZip(
dbFile: File,
output: java.io.OutputStream,
settingsJson: String? = null,
manifestJson: String? = null,
): Long = ZipOutputStream(output).use { zip ->
zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME))
val entryBytes = dbFile.inputStream().use { input -> input.copyTo(zip) }
zip.closeEntry()
if (settingsJson != null) {
zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME))
zip.write(settingsJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
if (manifestJson != null) {
zip.putNextEntry(ZipEntry(MANIFEST_ENTRY_NAME))
zip.write(manifestJson.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
entryBytes
}

/** True when [file] begins with the SQLite 3 magic. Pure; used by [importFrom] and the tests. */
Expand Down
54 changes: 51 additions & 3 deletions android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream

/**
Expand Down Expand Up @@ -135,7 +139,7 @@ class BackupSettingsCodecTest {
BackupSettingsCodec.encode(mapOf("profile.age" to 41, "profile.weightKg" to 90.5)),
)
val backup = tmp.newFile("with-settings.noopbak")
DataBackup.writeBackupZip(liveDb, backup, settingsJson)
DataBackup.writeBackupZip(liveDb, backup.outputStream(), settingsJson)

val stagedDb = tmp.newFile()
val stagedSettings = File(tmp.root, "staged-settings.json")
Expand All @@ -154,7 +158,7 @@ class BackupSettingsCodecTest {
@Test fun legacySingleEntryZipStagesDbAndNoSettings() {
val liveDb = fakeSqlite("legacy-rows")
val backup = tmp.newFile("legacy.noopbak")
DataBackup.writeBackupZip(liveDb, backup) // settingsJson defaults null → pre-#1000 shape
DataBackup.writeBackupZip(liveDb, backup.outputStream()) // settingsJson defaults null → pre-#1000 shape

val stagedDb = tmp.newFile()
val stagedSettings = File(tmp.root, "staged-settings.json")
Expand All @@ -171,14 +175,58 @@ class BackupSettingsCodecTest {
// The pre-#1000 call shape (no settingsDest) keeps working for a 2-entry zip.
val liveDb = fakeSqlite("rows2")
val backup = tmp.newFile("two-entry.noopbak")
DataBackup.writeBackupZip(liveDb, backup, """{"profile.age":30}""")
DataBackup.writeBackupZip(liveDb, backup.outputStream(), """{"profile.age":30}""")

val stagedDb = tmp.newFile()
val result = DataBackup.stageBackupSqlite(backup.inputStream(), DataBackup.peekHeader(backup), stagedDb)
assertEquals(DataBackup.StageResult.OK, result)
assertEquals(liveDb.readBytes().toList(), stagedDb.readBytes().toList())
}

@Test fun productionArchiveIncludesManifestAndCountsOnlyDatabaseBytes() {
val liveDb = fakeSqlite("database rows")
val backup = tmp.newFile("with-manifest.noopbak")
val settings = """{"profile.age":41}"""
val manifest = """{"platform":"android","note":"é"}"""
val copiedBytes = DataBackup.writeBackupZip(liveDb, backup.outputStream(), settings, manifest)

assertEquals(liveDb.length(), copiedBytes)
// ZipFile reads the central directory, unlike the streaming restore path. Opening it proves
// the writer finished the archive; entry order and payloads must match the live export contract.
ZipFile(backup).use { zip ->
val entries = zip.entries().toList()
assertEquals(listOf("noop-backup.sqlite", "settings.json", "manifest.json"), entries.map { it.name })
assertEquals(liveDb.readBytes().toList(), zip.getInputStream(entries[0]).use { it.readBytes().toList() })
assertEquals(settings, zip.getInputStream(entries[1]).bufferedReader(Charsets.UTF_8).use { it.readText() })
assertEquals(manifest, zip.getInputStream(entries[2]).bufferedReader(Charsets.UTF_8).use { it.readText() })
}
val staged = tmp.newFile()
assertEquals(DataBackup.StageResult.OK,
DataBackup.stageBackupSqlite(backup.inputStream(), DataBackup.peekHeader(backup), staged))
assertEquals(liveDb.readBytes().toList(), staged.readBytes().toList())
}

@Test fun manifestWithoutSettingsStillFollowsDatabase() {
val liveDb = fakeSqlite("rows")
val backup = tmp.newFile("manifest-only.noopbak")
DataBackup.writeBackupZip(liveDb, backup.outputStream(), manifestJson = "{}")
ZipFile(backup).use { zip ->
assertEquals(listOf("noop-backup.sqlite", "manifest.json"), zip.entries().toList().map { it.name })
}
}

@Test fun archiveWriteFailurePropagatesAndClosesOutput() {
var closed = false
val failingOutput = object : OutputStream() {
override fun write(value: Int) { throw IOException("synthetic provider failure") }
override fun close() { closed = true }
}
assertThrows(IOException::class.java) {
failingOutput.use { DataBackup.writeBackupZip(fakeSqlite("rows"), it, manifestJson = "{}") }
}
assertTrue("Failed export must release its output stream", closed)
}

@Test fun zipWithNonCanonicalSqliteEntryIsRejected() {
val backup = tmp.newFile("wrong-entry.noopbak")
ZipOutputStream(backup.outputStream()).use { zip ->
Expand Down
Binary file modified android/app/src/test/java/com/noop/data/BackupSyncFileIoTest.kt
Binary file not shown.
Loading