bca92a34882a

feat: Complete import/export parity (v2.3.33)

- Add Proton Pass JSON detection and login import parsing - Update file import provider copy to include Proton Pass - Mark T012 complete, with credential-exchange passkeys deferred to T015 Verified: ./gradlew compileDebugKotlin testDebugUnitTest --tests com.peachpasswords.ui.vault.ImportParsersTest Verified: ./gradlew testDebugUnitTest :app:assembleDebug lintDebug

time
2026-06-26 09:37 UTC · 4 months ago
machine
koh2
parent
5460e6c45135
4 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 −0
app/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 {

view file

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)

view file

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         )

view file

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(

view file