bca92a34882a
feat: Complete import/export parity (v2.3.33)
- time
- 2026-06-26 09:37 UTC · 4 months ago
- machine
- koh2
- parent
- 5460e6c45135
↓ peach-android-bca92a34.tar.gzatlas at this save
files changed4 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/vault/CsvImport.kt+64 −1app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+2 −2app/src/test/java/com/peachpasswords/ui/vault/ImportParsersTest.kt+49 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23320020- versionName "2.3.32"19+ versionCode 23330020+ versionName "2.3.33"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/vault/CsvImport.kt+64 −1
@@ -10,6 +10,7 @@1010 * Supported providers (detected from header / content shape):1111 * - "Bitwarden CSV" (folder,favorite,type,name,login_uri,login_username,login_password,login_totp,notes)1212 * - "Bitwarden JSON" ({"items":[...]})13+ * - "Proton Pass JSON" ({"vaults":{...}})1314 * - "1Password CSV" (Title,Url,Username,Password,... — 1Password v8 export)1415 * - "Chrome CSV" (name,url,username,password)1516 * - "Edge CSV" (same columns as Chrome — Chromium family)@@ -40,6 +41,7 @@4041 // Display labels used both for detection return values and "Detected: X export".4142 const val BITWARDEN_CSV = "Bitwarden CSV"4243 const val BITWARDEN_JSON = "Bitwarden JSON"44+ const val PROTONPASS_JSON = "Proton Pass JSON"4345 const val ONEPASSWORD_CSV = "1Password CSV"4446 const val CHROME_CSV = "Chrome CSV"4547 const val EDGE_CSV = "Edge CSV"@@ -54,6 +56,10 @@5456 val trimmed = content.trim()5557 if (trimmed.isEmpty()) return null5658 59+ // Proton Pass JSON: an object carrying a "vaults" map.60+ if (trimmed.startsWith("{") && Regex("\"vaults\"\\s*:\\s*\\{").containsMatchIn(trimmed)) {61+ return PROTONPASS_JSON62+ }5763 // Bitwarden JSON: an object carrying an "items" array. Cheap shape check5864 // rather than a full parse — the parser does the real decode.5965 if (trimmed.startsWith("{") && Regex("\"items\"\\s*:\\s*\\[").containsMatchIn(trimmed)) {@@ -89,6 +95,7 @@8995 * surfaces as the "No entries found" error.9096 */9197 fun parse(content: String, provider: String): List<ParsedEntry> = when (provider) {98+ PROTONPASS_JSON -> parseProtonPassJson(content)9299 BITWARDEN_JSON -> parseBitwardenJson(content)93100 BITWARDEN_CSV -> parseCsvByHeader(content, BitwardenCsvMap)94101 CHROME_CSV, EDGE_CSV -> parseCsvByHeader(content, ChromeCsvMap)@@ -205,6 +212,46 @@205212 }206213 }207214 215+ // ---- Proton Pass JSON -------------------------------------------------216+217+ private fun parseProtonPassJson(content: String): List<ParsedEntry> {218+ val vaults = extractObject(content, "vaults") ?: return emptyList()219+ val entries = mutableListOf<ParsedEntry>()220+ for (vault in splitTopLevelObjects(vaults.removePrefix("{").removeSuffix("}"))) {221+ if (jsonString(vault, "name").equals("recycle bin", ignoreCase = true)) continue222+ val itemsArray = extractArray(vault, "items") ?: continue223+ for (item in splitTopLevelObjects(itemsArray)) {224+ val itemData = extractObject(item, "data") ?: item225+ val itemType = jsonString(itemData, "type") ?: jsonString(item, "type")226+ if (itemType != "login") continue227+ val body = extractObject(itemData, "content") ?: ""228+ val metadata = extractObject(itemData, "metadata") ?: ""229+ val password = firstNonBlank(jsonString(body, "password"), jsonString(body, "itemPassword"))230+ ?: continue231+ entries += ParsedEntry(232+ title = firstNonBlank(233+ jsonString(metadata, "name"),234+ jsonString(item, "name"),235+ jsonString(item, "title"),236+ jsonString(body, "itemName"),237+ ) ?: "Untitled",238+ username = firstNonBlank(239+ jsonString(body, "username"),240+ jsonString(body, "itemUsername"),241+ jsonString(body, "email"),242+ jsonString(body, "itemEmail"),243+ jsonString(item, "aliasEmail"),244+ ),245+ password = password,246+ url = firstNonBlank(extractFirstStringFromArray(body, "urls"), jsonString(body, "url")),247+ notes = firstNonBlank(jsonString(metadata, "note"), jsonString(metadata, "notes")),248+ totp = firstNonBlank(jsonString(body, "totpUri"), jsonString(body, "itemTotpUri")),249+ )250+ }251+ }252+ return entries253+ }254+208255 // ---- Bitwarden JSON ---------------------------------------------------209256 210257 /**@@ -233,7 +280,11 @@233280 }234281 235282 private fun extractItemsArray(content: String): String? {236- val key = Regex("\"items\"\\s*:\\s*\\[").find(content) ?: return null283+ return extractArray(content, "items")284+ }285+286+ private fun extractArray(content: String, keyName: String): String? {287+ val key = Regex("\"${Regex.escape(keyName)}\"\\s*:\\s*\\[").find(content) ?: return null237288 val start = key.range.last // index of '['238289 var depth = 0239290 var i = start@@ -319,6 +370,15 @@319370 return jsonString(loginBlock, "uri")320371 }321372 373+ private fun extractFirstStringFromArray(scope: String, key: String): String? {374+ val body = extractArray(scope, key) ?: return null375+ return Regex("\"((?:[^\"\\\\]|\\\\.)*)\"").find(body)376+ ?.groupValues377+ ?.get(1)378+ ?.let(::unescapeJson)379+ ?.ifEmpty { null }380+ }381+322382 private fun jsonString(scope: String, key: String): String? {323383 val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(scope)324384 ?: return null@@ -329,6 +389,9 @@329389 val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(scope) ?: return null330390 return m.groupValues[1].toIntOrNull()331391 }392+393+ private fun firstNonBlank(vararg values: String?): String? =394+ values.firstOrNull { !it.isNullOrBlank() }?.trim()332395 333396 private fun unescapeJson(s: String): String {334397 val sb = StringBuilder(s.length)app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+2 −2
@@ -384,7 +384,7 @@384384 Spacer(Modifier.size(12.dp))385385 OptionCard(386386 title = "From file",387- subtitle = "Bitwarden, 1Password, Chrome, Safari, Edge CSV",387+ subtitle = "Bitwarden, Proton Pass, 1Password, Chrome, Safari, Edge CSV",388388 onClick = onFile,389389 )390390 }@@ -462,7 +462,7 @@462462 )463463 Spacer(Modifier.size(6.dp))464464 Text(465- "Supports Bitwarden, 1Password, Chrome/Edge, and Safari exports.",465+ "Supports Bitwarden, Proton Pass, 1Password, Chrome/Edge, and Safari exports.",466466 color = peachColors.T2,467467 style = PeachType.hint,468468 )app/src/test/java/com/peachpasswords/ui/vault/ImportParsersTest.kt+49 −0
@@ -44,6 +44,55 @@4444 assertEquals(2, r.entries.size)4545 }4646 47+ @Test fun proton_pass_json_detects_and_parses_login_items() {48+ val json = """49+ {50+ "vaults": {51+ "main": {52+ "name": "Personal",53+ "items": [54+ {55+ "data": {56+ "type": "login",57+ "metadata": { "name": "Proton Site", "note": "saved note" },58+ "content": {59+ "username": "june",60+ "password": "hunter2",61+ "urls": ["https://example.com"],62+ "totpUri": "otpauth://totp/Example:june?secret=JBSWY3DPEHPK3PXP"63+ }64+ }65+ }66+ ]67+ },68+ "trash": {69+ "name": "Recycle Bin",70+ "items": [71+ {72+ "data": {73+ "type": "login",74+ "metadata": { "name": "Deleted" },75+ "content": { "username": "x", "password": "y" }76+ }77+ }78+ ]79+ }80+ }81+ }82+ """.trimIndent()83+84+ assertEquals(CsvImport.PROTONPASS_JSON, CsvImport.detectProvider(json))85+ val rows = CsvImport.parse(json, CsvImport.PROTONPASS_JSON)86+87+ assertEquals(1, rows.size)88+ assertEquals("Proton Site", rows[0].title)89+ assertEquals("june", rows[0].username)90+ assertEquals("hunter2", rows[0].password)91+ assertEquals("https://example.com", rows[0].url)92+ assertEquals("saved note", rows[0].notes)93+ assertEquals("otpauth://totp/Example:june?secret=JBSWY3DPEHPK3PXP", rows[0].totp)94+ }95+4796 @Test fun peachscript_export_is_recognised() {4897 val out = PeachScriptExport.buildEncrypted(4998 listOf(