From b4bc08a594304dce34b7c18d09ee3e2522f48f48 Mon Sep 17 00:00:00 2001 From: Rene Andre Bedonia Jocsing Date: Wed, 13 May 2026 07:56:33 +0800 Subject: [PATCH 1/2] Fix file upload handling to support .md files for persona and skill uploads. not .json --- src/content/ui/CharacterList.svelte | 45 ++---- src/content/ui/SkillList.svelte | 45 ++---- tests/e2e-android/android.spec.js | 39 +++-- tests/e2e/extension.spec.js | 41 ++--- .../files/native-file-input.test.js | 15 ++ tests/integration/ui/ListComponents.test.js | 147 ++++++++++++++++-- 6 files changed, 215 insertions(+), 117 deletions(-) diff --git a/src/content/ui/CharacterList.svelte b/src/content/ui/CharacterList.svelte index ff095e7..5509c09 100644 --- a/src/content/ui/CharacterList.svelte +++ b/src/content/ui/CharacterList.svelte @@ -6,7 +6,7 @@ import { openNativeFilePicker } from "../files/native-file-input.js"; let characters = $state([...appState.characters]); - let fileInput = $state(null); + let uploadInput = $state(null); // Editing state let editingId = $state(null); @@ -30,38 +30,17 @@ } function triggerImport() { - openNativeFilePicker(fileInput, { preferSingle: true }); - } - - async function handleImport(event) { - const file = event.target.files[0]; - if (!file) return; - - try { - const text = await file.text(); - const raw = JSON.parse(text); - const { normalizeCharacters } = await import("../storage.js"); - const normalized = normalizeCharacters(raw); - - await chrome.storage.local.set({ - [STORAGE_KEYS.characters]: normalized, - }); - - if (appState.ui) { - appState.ui.showToast("Characters imported."); - } - } catch (err) { - console.error("Import failed:", err); - if (appState.ui) { - appState.ui.showToast("Import failed: Invalid JSON."); - } - } - event.target.value = ""; + openNativeFilePicker(uploadInput, { preferSingle: true }); } async function handleUpload(event) { const file = event.target.files && event.target.files[0]; if (!file) return; + if (!file.name.toLowerCase().endsWith(".md")) { + if (appState.ui) appState.ui.showToast("Only .md files are supported for persona uploads."); + event.target.value = ""; + return; + } const content = await file.text(); const name = file.name.replace(/\.md$/i, "") || `char-${appState.characters.length + 1}`; @@ -177,13 +156,6 @@ - @@ -192,7 +164,8 @@ diff --git a/src/content/ui/SkillList.svelte b/src/content/ui/SkillList.svelte index cc17020..1496d7a 100644 --- a/src/content/ui/SkillList.svelte +++ b/src/content/ui/SkillList.svelte @@ -6,7 +6,7 @@ import { openNativeFilePicker } from "../files/native-file-input.js"; let skills = $state([...appState.skills]); - let fileInput = $state(null); + let uploadInput = $state(null); // Editing state let editingId = $state(null); @@ -29,38 +29,17 @@ } function triggerImport() { - openNativeFilePicker(fileInput, { preferSingle: true }); - } - - async function handleImportSkills(event) { - const file = event.target.files[0]; - if (!file) return; - - try { - const text = await file.text(); - const raw = JSON.parse(text); - const { normalizeSkills } = await import("../storage.js"); - const normalized = normalizeSkills(raw); - - await chrome.storage.local.set({ - [STORAGE_KEYS.skills]: normalized, - }); - - if (appState.ui) { - appState.ui.showToast("Skills imported successfully."); - } - } catch (err) { - console.error("Import failed:", err); - if (appState.ui) { - appState.ui.showToast("Import failed: Invalid JSON."); - } - } - event.target.value = ""; + openNativeFilePicker(uploadInput, { preferSingle: true }); } async function handleUpload(event) { const file = event.target.files && event.target.files[0]; if (!file) return; + if (!file.name.toLowerCase().endsWith(".md")) { + if (appState.ui) appState.ui.showToast("Only .md files are supported for skills."); + event.target.value = ""; + return; + } const content = await file.text(); const name = file.name.replace(/\.md$/i, "") || `skill-${appState.skills.length + 1}`; @@ -162,13 +141,6 @@ - @@ -177,7 +149,8 @@ diff --git a/tests/e2e-android/android.spec.js b/tests/e2e-android/android.spec.js index 33c0177..aa892b3 100644 --- a/tests/e2e-android/android.spec.js +++ b/tests/e2e-android/android.spec.js @@ -102,28 +102,29 @@ test("Upload File requests single-file mode on Android", async ({ page }) => { .toBe(true); }); -test("drawer import and upload inputs stay single-file on Android", async ({ page }) => { +test("drawer import inputs stay single-file on Android", async ({ page }) => { await openDrawer(page); await page.evaluate(() => { const modes = {}; + const accepts = {}; window.__mockDeepSeek.drawerFilePickerModes = modes; + window.__mockDeepSeek.drawerFilePickerAccepts = accepts; - const names = ["skillImport", "characterImport", "memoryImport"]; - const jsonInputs = Array.from( - document.querySelectorAll('#bds-drawer input[type="file"][accept=".json"]'), - ); - jsonInputs.forEach((input, index) => { - input.addEventListener("click", (event) => { - modes[names[index]] = input.multiple; - event.preventDefault(); - }, { once: true }); - }); + const jsonInputs = document.querySelectorAll('#bds-drawer input[type="file"][accept=".json"]'); + accepts.jsonInputCount = jsonInputs.length; + const memoryImportInput = jsonInputs[0]; + accepts.memoryImport = memoryImportInput.accept; + memoryImportInput.addEventListener("click", (event) => { + modes.memoryImport = memoryImportInput.multiple; + event.preventDefault(); + }, { once: true }); for (const [key, selector] of [ - ["skillUpload", "#bds-skill-upload"], - ["characterUpload", "#bds-char-upload"], + ["skillImport", "#bds-skill-upload"], + ["characterImport", "#bds-char-upload"], ]) { const input = document.querySelector(selector); + accepts[key] = input.accept; input.addEventListener("click", (event) => { modes[key] = input.multiple; event.preventDefault(); @@ -135,8 +136,6 @@ test("drawer import and upload inputs stay single-file on Android", async ({ pag await importButtons.nth(0).click({ force: true }); await importButtons.nth(1).click({ force: true }); await importButtons.nth(2).click({ force: true }); - await page.locator("#bds-skill-upload").dispatchEvent("click"); - await page.locator("#bds-char-upload").dispatchEvent("click"); await expect .poll(() => page.evaluate(() => window.__mockDeepSeek.drawerFilePickerModes)) @@ -144,8 +143,14 @@ test("drawer import and upload inputs stay single-file on Android", async ({ pag skillImport: false, characterImport: false, memoryImport: false, - skillUpload: false, - characterUpload: false, + }); + await expect + .poll(() => page.evaluate(() => window.__mockDeepSeek.drawerFilePickerAccepts)) + .toEqual({ + jsonInputCount: 1, + skillImport: ".md", + characterImport: ".md", + memoryImport: ".json", }); }); diff --git a/tests/e2e/extension.spec.js b/tests/e2e/extension.spec.js index 145b265..e3c4729 100644 --- a/tests/e2e/extension.spec.js +++ b/tests/e2e/extension.spec.js @@ -178,28 +178,29 @@ test("Upload File keeps multiple mode in the web flow", async ({ page }) => { .toBe(true); }); -test("drawer import and upload inputs stay single-file in the web flow", async ({ page }) => { +test("drawer import inputs stay single-file in the web flow", async ({ page }) => { await openDrawer(page); await page.evaluate(() => { const modes = {}; + const accepts = {}; window.__mockDeepSeek.drawerFilePickerModes = modes; - - const names = ["skillImport", "characterImport", "memoryImport"]; - const jsonInputs = Array.from( - document.querySelectorAll('#bds-drawer input[type="file"][accept=".json"]'), - ); - jsonInputs.forEach((input, index) => { - input.addEventListener("click", (event) => { - modes[names[index]] = input.multiple; - event.preventDefault(); - }, { once: true }); - }); + window.__mockDeepSeek.drawerFilePickerAccepts = accepts; + + const jsonInputs = document.querySelectorAll('#bds-drawer input[type="file"][accept=".json"]'); + accepts.jsonInputCount = jsonInputs.length; + const memoryImportInput = jsonInputs[0]; + accepts.memoryImport = memoryImportInput.accept; + memoryImportInput.addEventListener("click", (event) => { + modes.memoryImport = memoryImportInput.multiple; + event.preventDefault(); + }, { once: true }); for (const [key, selector] of [ - ["skillUpload", "#bds-skill-upload"], - ["characterUpload", "#bds-char-upload"], + ["skillImport", "#bds-skill-upload"], + ["characterImport", "#bds-char-upload"], ]) { const input = document.querySelector(selector); + accepts[key] = input.accept; input.addEventListener("click", (event) => { modes[key] = input.multiple; event.preventDefault(); @@ -211,8 +212,6 @@ test("drawer import and upload inputs stay single-file in the web flow", async ( await importButtons.nth(0).click(); await importButtons.nth(1).click(); await importButtons.nth(2).click(); - await page.locator("#bds-skill-upload").dispatchEvent("click"); - await page.locator("#bds-char-upload").dispatchEvent("click"); await expect .poll(() => page.evaluate(() => window.__mockDeepSeek.drawerFilePickerModes)) @@ -220,8 +219,14 @@ test("drawer import and upload inputs stay single-file in the web flow", async ( skillImport: false, characterImport: false, memoryImport: false, - skillUpload: false, - characterUpload: false, + }); + await expect + .poll(() => page.evaluate(() => window.__mockDeepSeek.drawerFilePickerAccepts)) + .toEqual({ + jsonInputCount: 1, + skillImport: ".md", + characterImport: ".md", + memoryImport: ".json", }); }); diff --git a/tests/integration/files/native-file-input.test.js b/tests/integration/files/native-file-input.test.js index dc80c34..43d2238 100644 --- a/tests/integration/files/native-file-input.test.js +++ b/tests/integration/files/native-file-input.test.js @@ -35,4 +35,19 @@ describe("openNativeFilePicker", () => { expect(input.click).toHaveBeenCalledOnce(); expect(input.multiple).toBe(true); }); + + it("preserves extension-only accept filters while preferring single-file mode", () => { + const input = createNativeInput(); + input.accept = ".json"; + input.click = vi.fn(() => { + expect(input.multiple).toBe(false); + expect(input.accept).toBe(".json"); + }); + + openNativeFilePicker(input, { preferSingle: true }); + + expect(input.click).toHaveBeenCalledOnce(); + expect(input.multiple).toBe(true); + expect(input.accept).toBe(".json"); + }); }); diff --git a/tests/integration/ui/ListComponents.test.js b/tests/integration/ui/ListComponents.test.js index fb3420f..be6f028 100644 --- a/tests/integration/ui/ListComponents.test.js +++ b/tests/integration/ui/ListComponents.test.js @@ -131,22 +131,86 @@ describe("memory, character, and skill components", () => { cleanup(); }); + it("CharacterList accepts lowercase .md persona uploads", async () => { + const { target, cleanup } = renderSvelte(CharacterList); + const uploadInput = target.querySelector("#bds-char-upload"); + const file = { + name: "test.md", + text: vi.fn(async () => "persona body"), + }; + + await triggerFileInput(uploadInput, file); + + expect(state.characters).toMatchObject([ + { + name: "test", + usage: "uploaded", + content: "persona body", + active: true, + }, + ]); + expect(state.ui.showToast).not.toHaveBeenCalledWith( + "Only .md files are supported for persona uploads.", + ); + cleanup(); + }); + + it("CharacterList rejects non-.md persona uploads", async () => { + state.characters = [ + { id: "c1", name: "Mage", usage: "rp", content: "wise", active: true }, + ]; + const initialCharacters = structuredClone(state.characters); + const { target, cleanup } = renderSvelte(CharacterList); + const uploadInput = target.querySelector("#bds-char-upload"); + const file = { + name: "notes.txt", + text: vi.fn(async () => "persona body"), + }; + + await triggerFileInput(uploadInput, file); + + expect(file.text).not.toHaveBeenCalled(); + expect(state.characters).toEqual(initialCharacters); + expect(state.ui.showToast).toHaveBeenCalledWith( + "Only .md files are supported for persona uploads.", + ); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + expect(bridgeMocks.pushConfigToPage).not.toHaveBeenCalled(); + cleanup(); + }); + + it("CharacterList accepts uppercase .MD persona uploads", async () => { + const { target, cleanup } = renderSvelte(CharacterList); + const uploadInput = target.querySelector("#bds-char-upload"); + const file = { + name: "TEST.MD", + text: vi.fn(async () => "persona body"), + }; + + await triggerFileInput(uploadInput, file); + + expect(state.characters.some((item) => item.name === "TEST")).toBe(true); + expect(state.ui.showToast).not.toHaveBeenCalledWith( + "Only .md files are supported for persona uploads.", + ); + cleanup(); + }); + it("CharacterList keeps import and persona uploads single-file", async () => { const { target, cleanup } = renderSvelte(CharacterList); await flushUi(); - const importInput = target.querySelector('input[type="file"][accept=".json"]'); + const uploadInput = target.querySelector("#bds-char-upload"); getButtonByText(target, "Import").click(); await flushUi(); expect(nativeFileInputMocks.openNativeFilePicker).toHaveBeenCalledWith( - importInput, + uploadInput, { preferSingle: true }, ); - expect(importInput.multiple).toBe(false); - - const uploadInput = target.querySelector("#bds-char-upload"); expect(uploadInput.multiple).toBe(false); + expect(uploadInput.accept).toBe(".md"); + expect(target.querySelector('input[type="file"][accept=".json"]')).toBeNull(); cleanup(); }); @@ -184,22 +248,85 @@ describe("memory, character, and skill components", () => { cleanup(); }); + it("SkillList accepts lowercase .md skill uploads", async () => { + const { target, cleanup } = renderSvelte(SkillList); + const uploadInput = target.querySelector("#bds-skill-upload"); + const file = { + name: "test.md", + text: vi.fn(async () => "skill body"), + }; + + await triggerFileInput(uploadInput, file); + + expect(state.skills).toMatchObject([ + { + name: "test", + content: "skill body", + active: true, + }, + ]); + expect(state.ui.showToast).not.toHaveBeenCalledWith( + "Only .md files are supported for skills.", + ); + cleanup(); + }); + + it("SkillList rejects non-.md skill uploads", async () => { + state.skills = [ + { id: "s1", name: "Debugger", content: "Inspect logs", active: true }, + ]; + const initialSkills = structuredClone(state.skills); + const { target, cleanup } = renderSvelte(SkillList); + const uploadInput = target.querySelector("#bds-skill-upload"); + const file = { + name: "notes.txt", + text: vi.fn(async () => "skill body"), + }; + + await triggerFileInput(uploadInput, file); + + expect(file.text).not.toHaveBeenCalled(); + expect(state.skills).toEqual(initialSkills); + expect(state.ui.showToast).toHaveBeenCalledWith( + "Only .md files are supported for skills.", + ); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + expect(bridgeMocks.pushConfigToPage).not.toHaveBeenCalled(); + cleanup(); + }); + + it("SkillList accepts uppercase .MD skill uploads", async () => { + const { target, cleanup } = renderSvelte(SkillList); + const uploadInput = target.querySelector("#bds-skill-upload"); + const file = { + name: "TEST.MD", + text: vi.fn(async () => "skill body"), + }; + + await triggerFileInput(uploadInput, file); + + expect(state.skills.some((item) => item.name === "TEST")).toBe(true); + expect(state.ui.showToast).not.toHaveBeenCalledWith( + "Only .md files are supported for skills.", + ); + cleanup(); + }); + it("SkillList keeps import and skill uploads single-file", async () => { const { target, cleanup } = renderSvelte(SkillList); await flushUi(); - const importInput = target.querySelector('input[type="file"][accept=".json"]'); + const uploadInput = target.querySelector("#bds-skill-upload"); getButtonByText(target, "Import").click(); await flushUi(); expect(nativeFileInputMocks.openNativeFilePicker).toHaveBeenCalledWith( - importInput, + uploadInput, { preferSingle: true }, ); - expect(importInput.multiple).toBe(false); - - const uploadInput = target.querySelector("#bds-skill-upload"); expect(uploadInput.multiple).toBe(false); + expect(uploadInput.accept).toBe(".md"); + expect(target.querySelector('input[type="file"][accept=".json"]')).toBeNull(); cleanup(); }); }); From cc8b97cca26de4ebc45f5ee2e49e2beb502f1f8a Mon Sep 17 00:00:00 2001 From: Rene Andre Bedonia Jocsing Date: Wed, 13 May 2026 08:21:55 +0800 Subject: [PATCH 2/2] Restore bad revert on native file and folder picker for Android; support .md file uploads - Added `nativePickFiles` function to handle file and folder picking via AndroidBridge. - Introduced `isNativeFilePickerAvailable` to check for picker availability. - Enhanced `WebViewBridge` to handle file picking requests and deliver results to JavaScript. - Updated `AttachMenu.svelte` and `ProjectsManager.svelte` to utilize the new native picker. - Implemented folder upload functionality with workspace file generation. - Added tests for the new file picker functionality and folder file building. --- android/app/build.gradle.kts | 1 + .../com/betterdeepseek/app/MainActivity.kt | 43 ++++ .../com/betterdeepseek/app/WebViewBridge.kt | 194 ++++++++++++++++++ .../betterdeepseek/app/WebViewBridgeTest.kt | 56 +++++ src/content/ui/AttachMenu.svelte | 50 ++++- src/content/ui/ProjectsManager.svelte | 83 +++++++- src/platform/android-bridge-shim.js | 3 + src/platform/android-file-picker.js | 110 ++++++++++ tests/e2e-android/android.spec.js | 177 +++++++++++++--- tests/e2e-android/helpers/android.js | 20 ++ .../platform/android-file-picker.test.js | 130 ++++++++++++ 11 files changed, 824 insertions(+), 43 deletions(-) create mode 100644 src/platform/android-file-picker.js create mode 100644 tests/integration/platform/android-file-picker.test.js diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index acbe2ed..756a5c6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.activity:activity-ktx:1.9.2") implementation("androidx.webkit:webkit:1.11.0") + implementation("androidx.documentfile:documentfile:1.0.1") implementation("com.google.android.material:material:1.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/android/app/src/main/java/com/betterdeepseek/app/MainActivity.kt b/android/app/src/main/java/com/betterdeepseek/app/MainActivity.kt index fd7ca90..f203e5d 100644 --- a/android/app/src/main/java/com/betterdeepseek/app/MainActivity.kt +++ b/android/app/src/main/java/com/betterdeepseek/app/MainActivity.kt @@ -73,6 +73,36 @@ class MainActivity : ComponentActivity() { ) } + @Volatile private var pendingPickFilesRequestId: String? = null + + private val multiFileLauncher: ActivityResultLauncher> = + registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + val requestId = pendingPickFilesRequestId ?: return@registerForActivityResult + pendingPickFilesRequestId = null + if (uris.isEmpty()) { + bridge.deliverPickError(requestId, "cancelled") + return@registerForActivityResult + } + Thread { + val files = uris.mapNotNull { uri -> bridge.readPickedContentUri(uri) } + bridge.deliverPickedFiles(requestId, files, null) + }.start() + } + + private val folderPickerLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri -> + val requestId = pendingPickFilesRequestId ?: return@registerForActivityResult + pendingPickFilesRequestId = null + if (treeUri == null) { + bridge.deliverPickError(requestId, "cancelled") + return@registerForActivityResult + } + Thread { + val (files, folderName) = bridge.readPickedFolderTree(treeUri) + bridge.deliverPickedFiles(requestId, files, folderName) + }.start() + } + private val proxyClient: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) @@ -92,6 +122,16 @@ class MainActivity : ComponentActivity() { bridge = WebViewBridge(applicationContext) cookieManager = CookieManager.getInstance() + bridge.onPickFiles = { mode, requestId -> + pendingPickFilesRequestId = requestId + runOnUiThread { + when (mode) { + "folder" -> folderPickerLauncher.launch(null) + else -> multiFileLauncher.launch(arrayOf("*/*")) + } + } + } + assetLoader = WebViewAssetLoader.Builder() .setDomain(getString(R.string.bds_asset_authority)) @@ -125,6 +165,7 @@ class MainActivity : ComponentActivity() { addJavascriptInterface(bridge, BRIDGE_NAME) webViewClient = bdsWebViewClient() webChromeClient = bdsWebChromeClient() + bridge.evaluateJs = { script -> evaluateJavascript(script, null) } isVerticalScrollBarEnabled = true setBackgroundColor(if (isPageDark) PAGE_BG_DARK else PAGE_BG_LIGHT) } @@ -179,6 +220,8 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { bridge.onThemeChanged = null + bridge.evaluateJs = null + bridge.onPickFiles = null webView.removeJavascriptInterface(BRIDGE_NAME) super.onDestroy() } diff --git a/android/app/src/main/java/com/betterdeepseek/app/WebViewBridge.kt b/android/app/src/main/java/com/betterdeepseek/app/WebViewBridge.kt index cf4e035..e6ea9b3 100644 --- a/android/app/src/main/java/com/betterdeepseek/app/WebViewBridge.kt +++ b/android/app/src/main/java/com/betterdeepseek/app/WebViewBridge.kt @@ -10,11 +10,13 @@ import android.os.Environment import android.os.Handler import android.os.Looper import android.provider.MediaStore +import android.provider.OpenableColumns import android.util.Base64 import android.util.Log import android.webkit.JavascriptInterface import android.widget.Toast import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile import java.io.File import java.io.FileOutputStream import java.util.concurrent.TimeUnit @@ -25,6 +27,9 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject +/** Text file entry returned by the native Android picker to JavaScript. */ +internal data class PickedFile(val name: String, val content: String) + /** * @JavascriptInterface object exposed to the WebView as `window.AndroidBridge`. * @@ -58,6 +63,18 @@ class WebViewBridge( /** Set by MainActivity to react to page theme changes without leaking the Activity window. */ @Volatile var onThemeChanged: ((isDark: Boolean) -> Unit)? = null + /** + * Set by MainActivity to evaluate JS in the WebView. Results from native picker launchers + * are delivered through CustomEvent instances in the page. + */ + @Volatile var evaluateJs: ((script: String) -> Unit)? = null + + /** + * Set by MainActivity to launch the native file or folder picker. + * Mode is "files" or "folder"; requestId is the JS correlation key. + */ + @Volatile var onPickFiles: ((mode: String, requestId: String) -> Unit)? = null + /** * Returns the last DeepSeek page theme written by the extension's theme.js via * chrome.storage.local (which the Android polyfill routes through [setStorage] as @@ -79,6 +96,161 @@ class WebViewBridge( onThemeChanged?.invoke(isDark) } + /** + * Called by JS to open the native Android file or folder picker. + * + * The result is delivered asynchronously as a page CustomEvent named + * "__bds_native_files_picked_". + */ + @JavascriptInterface + fun pickFiles(mode: String?, requestId: String?) { + val safeId = + requestId + ?.filter { it.isLetterOrDigit() || it == '-' } + ?.take(64) + ?: return + if (safeId.isEmpty()) return + + val safeMode = if (mode == "folder") "folder" else "files" + val handler = onPickFiles + if (handler == null) { + deliverPickError(safeId, "cancelled") + return + } + handler.invoke(safeMode, safeId) + } + + internal fun deliverPickedFiles( + requestId: String, + files: List, + folderName: String? + ) { + val filesJson = JSONArray() + for (file in files) { + filesJson.put( + JSONObject().apply { + put("name", file.name) + put("content", file.content) + } + ) + } + val payload = + JSONObject().apply { + put("files", filesJson) + if (folderName != null) put("folderName", folderName) + } + deliverPickResult(requestId, payload) + } + + internal fun deliverPickError(requestId: String, error: String) { + val payload = + JSONObject().apply { + put("error", error) + put("files", JSONArray()) + } + deliverPickResult(requestId, payload) + } + + private fun deliverPickResult(requestId: String, payload: JSONObject) { + val safeId = requestId.filter { it.isLetterOrDigit() || it == '-' }.take(64) + if (safeId.isEmpty()) return + val payloadLiteral = JSONObject.quote(payload.toString()) + val script = + "(function(){try{var d=JSON.parse($payloadLiteral);" + + "window.dispatchEvent(new CustomEvent('__bds_native_files_picked_$safeId'," + + "{detail:d}));}catch(e){}})();" + mainHandler.post { evaluateJs?.invoke(script) } + } + + internal fun readPickedContentUri(uri: Uri): PickedFile? { + return try { + val name = getDisplayName(uri) ?: return null + if (!isTextFileExtension(name)) return null + + val length = getContentLength(uri) + if (length > MAX_PICKED_FILE_SIZE) return null + + val content = + context.contentResolver.openInputStream(uri)?.use { stream -> + stream.bufferedReader(Charsets.UTF_8).readText() + } ?: return null + if (content.any { it.code == 0 }) return null + if (content.toByteArray(Charsets.UTF_8).size > MAX_PICKED_FILE_SIZE) return null + PickedFile(name, content) + } catch (t: Throwable) { + Log.w(TAG, "readPickedContentUri failed for $uri", t) + null + } + } + + internal fun readPickedFolderTree(treeUri: Uri): Pair, String> { + val docTree = DocumentFile.fromTreeUri(context, treeUri) + ?: return Pair(emptyList(), "folder") + val folderName = docTree.name ?: "folder" + val files = mutableListOf() + traverseDocumentTree(docTree, "", files, 0) + return Pair(files, folderName) + } + + private fun traverseDocumentTree( + dir: DocumentFile, + pathPrefix: String, + out: MutableList, + depth: Int + ) { + if (depth > MAX_FOLDER_DEPTH) return + for (child in dir.listFiles()) { + val name = child.name ?: continue + val relPath = if (pathPrefix.isEmpty()) name else "$pathPrefix/$name" + if (isSkippedPath(relPath)) continue + if (child.isDirectory) { + traverseDocumentTree(child, relPath, out, depth + 1) + } else if (child.isFile) { + val picked = readDocumentFile(child, relPath) ?: continue + out.add(picked) + } + } + } + + private fun readDocumentFile(file: DocumentFile, relPath: String): PickedFile? { + val name = file.name ?: return null + if (!isTextFileExtension(name)) return null + if (file.length() > MAX_PICKED_FILE_SIZE) return null + return try { + val content = + context.contentResolver.openInputStream(file.uri)?.use { stream -> + stream.bufferedReader(Charsets.UTF_8).readText() + } ?: return null + if (content.any { it.code == 0 }) return null + if (content.toByteArray(Charsets.UTF_8).size > MAX_PICKED_FILE_SIZE) return null + PickedFile(relPath, content) + } catch (t: Throwable) { + Log.w(TAG, "readDocumentFile failed for $relPath", t) + null + } + } + + private fun getDisplayName(uri: Uri): String? = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (!cursor.moveToFirst() || index < 0) null else cursor.getString(index) + } + + private fun getContentLength(uri: Uri): Long = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.SIZE) + if (!cursor.moveToFirst() || index < 0 || cursor.isNull(index)) -1L + else cursor.getLong(index) + } ?: -1L + + private fun isTextFileExtension(filename: String): Boolean { + val ext = filename.substringAfterLast('.', "").lowercase() + return ext.isNotEmpty() && ext in TEXT_EXTENSIONS + } + + private fun isSkippedPath(relPath: String): Boolean = + relPath.split("/").any { it in SKIP_DIRS } + @JavascriptInterface fun getStorage(key: String?): String? { if (key.isNullOrEmpty()) return null @@ -567,5 +739,27 @@ class WebViewBridge( // polyfill routes chrome.storage.local.set({ bds_page_is_dark: ... }) through setStorage, // so getLastKnownIsDark() and the polyfill share the same SharedPreferences key. internal const val KEY_LAST_PAGE_DARK = "bds_page_is_dark" + + /** Max bytes per native-picked text file. */ + internal const val MAX_PICKED_FILE_SIZE = 2L * 1024 * 1024 + + private const val MAX_FOLDER_DEPTH = 15 + + private val TEXT_EXTENSIONS = + setOf( + "js", "ts", "jsx", "tsx", "svelte", "vue", "html", "css", "scss", + "json", "md", "txt", "py", "c", "cpp", "h", "hpp", "java", "go", + "rs", "rb", "php", "sh", "yml", "yaml", "toml", "ini", "csv", "sql", + "xml", "env", "cs", "csproj", "sln", "fs", "fsproj", "razor", + "swift", "kt", "dart" + ) + + private val SKIP_DIRS = + setOf( + "node_modules", ".git", ".github", ".svn", "dist", "build", + "__pycache__", ".gradle", ".idea", ".vscode", ".vs", "vendor", + ".next", ".cache", "bin", "obj", "out", "target", "dist-chrome", + "dist-firefox" + ) } } diff --git a/android/app/src/test/java/com/betterdeepseek/app/WebViewBridgeTest.kt b/android/app/src/test/java/com/betterdeepseek/app/WebViewBridgeTest.kt index 04dd713..e135ade 100644 --- a/android/app/src/test/java/com/betterdeepseek/app/WebViewBridgeTest.kt +++ b/android/app/src/test/java/com/betterdeepseek/app/WebViewBridgeTest.kt @@ -361,4 +361,60 @@ class WebViewBridgeTest { assertEquals(401, response.getInt("status")) assertTrue(response.getBoolean("authRejected")) } + + @Test + fun `pickFiles invokes onPickFiles with files mode and sanitized requestId`() { + var capturedMode: String? = null + var capturedId: String? = null + bridge.onPickFiles = { mode, id -> + capturedMode = mode + capturedId = id + } + + bridge.pickFiles("files", "ab!@#cd--12") + + assertEquals("files", capturedMode) + assertEquals("abcd--12", capturedId) + } + + @Test + fun `pickFiles normalizes unknown mode to files`() { + var capturedMode: String? = null + bridge.onPickFiles = { mode, _ -> capturedMode = mode } + + bridge.pickFiles("banana", "abc-123") + + assertEquals("files", capturedMode) + } + + @Test + fun `pickFiles passes folder mode unchanged`() { + var capturedMode: String? = null + bridge.onPickFiles = { mode, _ -> capturedMode = mode } + + bridge.pickFiles("folder", "abc-123") + + assertEquals("folder", capturedMode) + } + + @Test + fun `pickFiles truncates requestId to 64 characters`() { + val longId = "a".repeat(80) + var capturedId: String? = null + bridge.onPickFiles = { _, id -> capturedId = id } + + bridge.pickFiles("files", longId) + + assertEquals(64, capturedId?.length) + } + + @Test + fun `pickFiles ignores invalid requestId without invoking handler`() { + var called = false + bridge.onPickFiles = { _, _ -> called = true } + + bridge.pickFiles("files", "!@#$%") + + assertFalse(called) + } } diff --git a/src/content/ui/AttachMenu.svelte b/src/content/ui/AttachMenu.svelte index 2fb414e..195eb0d 100644 --- a/src/content/ui/AttachMenu.svelte +++ b/src/content/ui/AttachMenu.svelte @@ -10,6 +10,11 @@ import { fetchAndConvertWebPage } from "../files/web-reader.js"; import { projectFilesToFile } from "../files/project-file-builder.js"; import { openNativeFilePicker } from "../files/native-file-input.js"; + import { + buildFolderFileFromNative, + isNativeFilePickerAvailable, + nativePickFiles, + } from "../../platform/android-file-picker.js"; import { getFilesForProject, setActiveProject, @@ -73,7 +78,8 @@ // wired up in WebView; the on-screen keyboard mic is always reachable. // On non-Android targets we keep the buttons visible and let the existing // runtime fallbacks (toast on missing API) handle older Chromium variants. - const supportsFolderUpload = !isAndroidTarget; + // Android native bridge re-enables folder upload when pickFiles exists. + const supportsFolderUpload = !isAndroidTarget || isNativeFilePickerAvailable(); const supportsVoiceInput = !isAndroidTarget; function hasGithubToken() { @@ -279,8 +285,25 @@ } } - function handleUploadFile() { + async function handleUploadFile() { closeMenu(); + if (isAndroidTarget && isNativeFilePickerAvailable()) { + try { + const result = await nativePickFiles("files"); + if (!result.cancelled && result.files && result.files.length > 0) { + for (const file of result.files) { + const blob = new Blob([file.content], { type: "text/plain" }); + injectFile(new File([blob], file.name, { type: "text/plain" })); + } + } + } catch (err) { + if (appState.ui) { + appState.ui.showToast(err?.message || "File pick failed."); + } + } + return; + } + if (nativeInput) { // Native picker behavior is selected via a file-flow strategy. Android's // "Upload File" path prefers the single-file strategy so WebView asks the @@ -292,11 +315,24 @@ async function handleUploadFolder() { closeMenu(); - if (!supportsFolderUpload) { - if (appState.ui) { - appState.ui.showToast( - "Folder upload is not supported on Android yet.", - ); + if (isAndroidTarget) { + if (isNativeFilePickerAvailable()) { + try { + const result = await nativePickFiles("folder"); + if (!result.cancelled && result.files && result.files.length > 0) { + const fakeFile = buildFolderFileFromNative( + result.files, + result.folderName, + ); + if (fakeFile) injectFile(fakeFile); + } + } catch (err) { + if (appState.ui) { + appState.ui.showToast(err?.message || "Folder pick failed."); + } + } + } else if (appState.ui) { + appState.ui.showToast("Folder upload requires a newer version of the app."); } return; } diff --git a/src/content/ui/ProjectsManager.svelte b/src/content/ui/ProjectsManager.svelte index a5d859e..e82350e 100644 --- a/src/content/ui/ProjectsManager.svelte +++ b/src/content/ui/ProjectsManager.svelte @@ -11,12 +11,17 @@ import { pushConfigToPage } from "../bridge.js"; import { pickFolderSelection } from "../../lib/utils/folder-picker.js"; import { openNativeFilePicker } from "../files/native-file-input.js"; + import { + isNativeFilePickerAvailable, + nativePickFiles, + } from "../../platform/android-file-picker.js"; let { onback } = $props(); const BDS_TARGET = process.env.BDS_TARGET || "chrome"; const isAndroidTarget = BDS_TARGET === "android"; - const supportsFolderUpload = !isAndroidTarget; + // Android native bridge re-enables folder upload when pickFiles exists. + const supportsFolderUpload = !isAndroidTarget || isNativeFilePickerAvailable(); // view: "list" | "detail" let view = $state("list"); @@ -116,7 +121,31 @@ goBack(); } - function triggerFileUpload() { + async function triggerFileUpload() { + if (isAndroidTarget && isNativeFilePickerAvailable()) { + try { + const result = await nativePickFiles("files"); + if (result.cancelled || !result.files || result.files.length === 0) { + return; + } + if (!fileInput) return; + const dataTransfer = new DataTransfer(); + for (const file of result.files) { + const blob = new Blob([file.content], { type: "text/plain" }); + dataTransfer.items.add( + new File([blob], file.name, { type: "text/plain" }), + ); + } + fileInput.files = dataTransfer.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + } catch (err) { + if (appState.ui) { + appState.ui.showToast(err?.message || "File pick failed."); + } + } + return; + } + openNativeFilePicker(fileInput, { preferSingle: isAndroidTarget }); } @@ -180,11 +209,51 @@ } async function handleFolderUpload() { - if (!supportsFolderUpload) { - if (appState.ui) { - appState.ui.showToast( - "Folder upload is not supported on Android yet.", - ); + if (isAndroidTarget) { + if (isNativeFilePickerAvailable()) { + uploading = true; + fileError = ""; + try { + const result = await nativePickFiles("folder"); + if (!result.cancelled && result.files && result.files.length > 0) { + const MAX_SIZE = 500 * 1024; + const filesToAdd = []; + let skippedCount = 0; + + for (const file of result.files) { + if (new TextEncoder().encode(file.content).length > MAX_SIZE) { + skippedCount++; + continue; + } + if (!file.content.trim()) { + skippedCount++; + continue; + } + filesToAdd.push({ name: file.name, content: file.content }); + } + + if (filesToAdd.length > 0) { + try { + await addProjectFilesBatch(selectedProject.id, filesToAdd); + } catch (err) { + fileError = `Storage error: ${err?.message || String(err)}`; + } + } + + if (skippedCount > 0) { + fileError = + `${filesToAdd.length} file${filesToAdd.length !== 1 ? "s" : ""} uploaded, ` + + `${skippedCount} skipped (too large or empty).`; + } + } + } catch (err) { + fileError = err?.message || "Folder pick failed."; + } finally { + uploading = false; + projectFiles = getFilesForProject(selectedProject.id); + } + } else if (appState.ui) { + appState.ui.showToast("Folder upload requires a newer version of the app."); } return; } diff --git a/src/platform/android-bridge-shim.js b/src/platform/android-bridge-shim.js index b3c077e..1552f0f 100644 --- a/src/platform/android-bridge-shim.js +++ b/src/platform/android-bridge-shim.js @@ -18,6 +18,9 @@ * getAssetUrl(relativePath: String): String * downloadBlob(base64, mimeType, fileName): void * reportTheme(isDark: Boolean): void // live bar-icon colour update; persistence via setStorage + * pickFiles(mode: String, requestId: String): void + * // mode: "files" | "folder"; result delivered as + * // CustomEvent("__bds_native_files_picked_" + requestId) */ function getBridge() { diff --git a/src/platform/android-file-picker.js b/src/platform/android-file-picker.js new file mode 100644 index 0000000..e9f32a2 --- /dev/null +++ b/src/platform/android-file-picker.js @@ -0,0 +1,110 @@ +/** + * Native Android file/folder picker bridge. + * + * Wraps AndroidBridge.pickFiles in a Promise API. The native side opens an + * unrestricted Android document picker and returns text-file contents to JS, + * which avoids WebView accept/MIME filtering problems for extensions such as + * .md on Android builds that do not know text/markdown. + */ + +export function isNativeFilePickerAvailable() { + return ( + typeof window !== "undefined" && + window.AndroidBridge != null && + typeof window.AndroidBridge.pickFiles === "function" + ); +} + +export function nativePickFiles(mode = "files") { + return new Promise((resolve, reject) => { + if (!isNativeFilePickerAvailable()) { + reject(new Error("[BDS] AndroidBridge.pickFiles not available")); + return; + } + + const requestId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : Date.now().toString(36) + Math.random().toString(36).slice(2); + const eventName = "__bds_native_files_picked_" + requestId; + + const handler = (event) => { + window.removeEventListener(eventName, handler); + const data = event.detail; + if (!data) { + resolve({ files: [] }); + return; + } + if (data.error === "cancelled" || data.cancelled) { + resolve({ files: [], cancelled: true }); + return; + } + if (data.error) { + reject(new Error(data.error)); + return; + } + resolve(data); + }; + + window.addEventListener(eventName, handler); + + try { + window.AndroidBridge.pickFiles(String(mode || "files"), requestId); + } catch (err) { + window.removeEventListener(eventName, handler); + reject(err); + } + }); +} + +export function buildFolderFileFromNative(files, folderName) { + if (!files || files.length === 0) return null; + + const tree = buildPathTree(files.map((file) => file.name)); + let content = + "Directory Tree:\n" + + renderPathTree(tree) + + "\n\n========================================\n\n"; + + for (const file of files) { + content += "\n\n--- [FILE: " + file.name + "] ---\n\n"; + content += file.content; + } + + const name = (folderName || "folder") + "_workspace.txt"; + const blob = new Blob([content], { type: "text/plain" }); + return new File([blob], name, { type: "text/plain" }); +} + +function buildPathTree(paths) { + const tree = {}; + for (const path of paths) { + const parts = path.split("/"); + let current = tree; + for (const part of parts) { + if (!current[part]) current[part] = {}; + current = current[part]; + } + } + return tree; +} + +function renderPathTree(tree, prefix = "") { + const keys = Object.keys(tree).sort((a, b) => { + const aIsDir = Object.keys(tree[a]).length > 0; + const bIsDir = Object.keys(tree[b]).length > 0; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.localeCompare(b); + }); + + let output = ""; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + const isLast = index === keys.length - 1; + output += prefix + (isLast ? "`-- " : "|-- ") + key + "\n"; + if (Object.keys(tree[key]).length > 0) { + output += renderPathTree(tree[key], prefix + (isLast ? " " : "| ")); + } + } + return output; +} diff --git a/tests/e2e-android/android.spec.js b/tests/e2e-android/android.spec.js index aa892b3..f6488f0 100644 --- a/tests/e2e-android/android.spec.js +++ b/tests/e2e-android/android.spec.js @@ -33,22 +33,20 @@ test("loads the bundle and surfaces the BDS toggle inside the WebView simulator" await expect(page.locator("#bds-toggle")).toBeVisible(); }); -test("hides the folder upload menu item on Android", async ({ page }) => { +test("shows the folder upload menu item on Android when native picker is available", async ({ page }) => { await page.locator(".bds-plus-btn").click({ force: true }); await expect(page.locator(".bds-attach-dropdown")).toBeVisible(); await expect( page.locator(".bds-attach-dropdown .bds-attach-item").filter({ hasText: "Upload Folder" }), - ).toHaveCount(0); + ).toBeVisible(); await expect( page.locator(".bds-attach-dropdown .bds-attach-item").filter({ hasText: "GitHub Repo" }), ).toBeVisible(); }); -test("Upload Folder button is hidden in Projects panel on Android", async ({ page }) => { - // Verify via Evaluate that the built content.js carries the Android - // target check and calls handleFolderUpload()'s toast path. - // The ProjectsManager.svelte mounts inside the drawer; we assert - // the Build-time define baked "android" into the module. +test("loads Android project panel code path", async ({ page }) => { + // Verify via Evaluate that the Android content bundle is active before + // project picker tests interact with drawer-managed UI. const builtForAndroid = await page.evaluate(() => { // The AttachMenu and ProjectsManager both gate on BDS_TARGET. // The Vite define inlines the string, so in dist-android/content.js @@ -61,11 +59,8 @@ test("Upload Folder button is hidden in Projects panel on Android", async ({ pag }); expect(builtForAndroid).toBe(true); - // A direct check: since BDS_TARGET is "android" in this build, the - // `supportsFolderUpload` flag in ProjectsManager.svelte is false, - // so no "+ Upload Folder" button is ever rendered. We confirm that - // the `handleFolderUpload` function's guard is reachable by checking - // that the module pattern matches the expected built output. + // Smoke check that the Android bundle is mounted before the project-specific + // folder picker tests below exercise the visible Upload Folder button. const folderUploadCalls = await page.evaluate(() => { // The AttachMenu's handleUploadFolder logs via toast when called // on Android. The toast module is window-accessible. We assert @@ -75,16 +70,56 @@ test("Upload Folder button is hidden in Projects panel on Android", async ({ pag expect(folderUploadCalls).toBe(true); }); +test("Upload Folder button is visible in Projects panel on Android", async ({ page }) => { + await openDrawer(page); + await page.evaluate(() => + chrome.storage.local.set({ + bds_projects: [ + { + id: "folder-prj", + name: "Folder Project", + description: "", + customInstructions: "", + createdAt: Date.now(), + }, + ], + bds_project_files: [], + }), + ); + + await page.evaluate(() => { + const btn = Array.from(document.querySelectorAll("#bds-drawer button")).find( + (button) => button.textContent.trim() === "Manage", + ); + btn?.scrollIntoView({ block: "nearest", behavior: "instant" }); + btn?.click(); + }); + await page + .locator("#bds-drawer .bds-skill-item") + .filter({ hasText: "Folder Project" }) + .click({ force: true }); + + await expect( + page.locator("#bds-drawer button").filter({ hasText: "Upload Folder" }), + ).toBeVisible(); +}); + test("hides the voice prompt mic button on Android", async ({ page }) => { await expect(page.locator(".bds-mic-btn")).toHaveCount(0); }); -test("Upload File requests single-file mode on Android", async ({ page }) => { +test("Upload File on Android uses native picker bridge and injects markdown", async ({ page }) => { await page.evaluate(() => { const input = document.querySelector("#native-file-input"); - window.__mockDeepSeek.uploadFileClickMultiple = null; + window.__mockDeepSeek.uploadInputClickedDirectly = false; input.click = () => { - window.__mockDeepSeek.uploadFileClickMultiple = input.multiple; + window.__mockDeepSeek.uploadInputClickedDirectly = true; + }; + window.__bdsNativeFilePicker = (mode) => { + window.__mockDeepSeek.nativeUploadFileMode = mode; + return { + files: [{ name: "android-notes.md", content: "# Android notes" }], + }; }; }); @@ -95,11 +130,40 @@ test("Upload File requests single-file mode on Android", async ({ page }) => { .click({ force: true }); await expect - .poll(() => page.evaluate(() => window.__mockDeepSeek.uploadFileClickMultiple)) - .toBe(false); + .poll(() => page.evaluate(() => window.__mockDeepSeek.getAttachedFiles())) + .toContain("android-notes.md"); await expect - .poll(() => page.evaluate(() => document.querySelector("#native-file-input").multiple)) - .toBe(true); + .poll(() => page.evaluate(() => window.__mockDeepSeek.nativeUploadFileMode)) + .toBe("files"); + expect(await page.evaluate(() => window.__mockDeepSeek.uploadInputClickedDirectly)).toBe(false); +}); + +test("Upload Folder on Android uses native picker bridge and injects workspace", async ({ page }) => { + await page.evaluate(() => { + window.__bdsNativeFilePicker = (mode) => { + window.__mockDeepSeek.nativeUploadFolderMode = mode; + return { + files: [ + { name: "src/index.js", content: 'console.log("hello");' }, + { name: "README.md", content: "# Project" }, + ], + folderName: "android-project", + }; + }; + }); + + await page.locator(".bds-plus-btn").click({ force: true }); + await page + .locator(".bds-attach-dropdown .bds-attach-item") + .filter({ hasText: "Upload Folder" }) + .click({ force: true }); + + await expect + .poll(() => page.evaluate(() => window.__mockDeepSeek.getAttachedFiles())) + .toContain("android-project_workspace.txt"); + await expect + .poll(() => page.evaluate(() => window.__mockDeepSeek.nativeUploadFolderMode)) + .toBe("folder"); }); test("drawer import inputs stay single-file on Android", async ({ page }) => { @@ -154,7 +218,7 @@ test("drawer import inputs stay single-file on Android", async ({ page }) => { }); }); -test("project Upload File requests single-file mode on Android", async ({ page }) => { +test("project Upload File on Android uses native picker bridge and stores markdown", async ({ page }) => { await openDrawer(page); // Seed a project directly in chrome.storage rather than going through the @@ -172,7 +236,7 @@ test("project Upload File requests single-file mode on Android", async ({ page } createdAt: Date.now(), }, ], - bds_project_files: { "regression-prj": [] }, + bds_project_files: [], }), ); @@ -194,23 +258,78 @@ test("project Upload File requests single-file mode on Android", async ({ page } await page.evaluate(() => { const input = document.querySelector('#bds-drawer input[type="file"][multiple]'); - window.__mockDeepSeek.projectUploadClickMultiple = null; - // Override click() rather than using addEventListener so the file-chooser - // dialog never opens. This matches the established pattern in test #5 - // ("Upload File requests single-file mode on Android"). + window.__mockDeepSeek.projectInputClickedDirectly = false; input.click = function () { - window.__mockDeepSeek.projectUploadClickMultiple = this.multiple; + window.__mockDeepSeek.projectInputClickedDirectly = true; + }; + window.__bdsNativeFilePicker = (mode) => { + window.__mockDeepSeek.projectNativePickerMode = mode; + return { + files: [{ name: "project-notes.md", content: "# Project notes" }], + }; }; }); await page.locator("#bds-drawer button").filter({ hasText: "Upload File" }).click({ force: true }); await expect - .poll(() => page.evaluate(() => window.__mockDeepSeek.projectUploadClickMultiple)) + .poll(() => page.evaluate(() => window.__mockDeepSeek.projectNativePickerMode)) + .toBe("files"); + await expect + .poll(() => page.evaluate(() => window.__mockDeepSeek.projectInputClickedDirectly)) .toBe(false); + await expect(page.locator("#bds-drawer").filter({ hasText: "project-notes.md" })).toBeVisible(); +}); + +test("project Upload Folder on Android uses native picker bridge", async ({ page }) => { + await openDrawer(page); + await page.evaluate(() => + chrome.storage.local.set({ + bds_projects: [ + { + id: "folder-upload-prj", + name: "Folder Upload Project", + description: "", + customInstructions: "", + createdAt: Date.now(), + }, + ], + bds_project_files: [], + }), + ); + + await page.evaluate(() => { + const btn = Array.from(document.querySelectorAll("#bds-drawer button")).find( + (button) => button.textContent.trim() === "Manage", + ); + btn?.scrollIntoView({ block: "nearest", behavior: "instant" }); + btn?.click(); + }); + await page + .locator("#bds-drawer .bds-skill-item") + .filter({ hasText: "Folder Upload Project" }) + .click({ force: true }); + + await page.evaluate(() => { + window.__bdsNativeFilePicker = (mode) => { + window.__mockDeepSeek.projectNativeFolderMode = mode; + return { + files: [ + { name: "README.md", content: "# Folder readme" }, + { name: "src/app.js", content: "console.log('folder');" }, + ], + folderName: "folder-upload", + }; + }; + }); + + await page.locator("#bds-drawer button").filter({ hasText: "Upload Folder" }).click({ force: true }); + await expect - .poll(() => page.evaluate(() => document.querySelector('#bds-drawer input[type="file"][multiple]').multiple)) - .toBe(true); + .poll(() => page.evaluate(() => window.__mockDeepSeek.projectNativeFolderMode)) + .toBe("folder"); + await expect(page.locator("#bds-drawer").filter({ hasText: "README.md" })).toBeVisible(); + await expect(page.locator("#bds-drawer").filter({ hasText: "src/app.js" })).toBeVisible(); }); test("imports a GitHub repository and commit history through the Android bridge", async ({ page }) => { diff --git a/tests/e2e-android/helpers/android.js b/tests/e2e-android/helpers/android.js index e30b90c..23f3091 100644 --- a/tests/e2e-android/helpers/android.js +++ b/tests/e2e-android/helpers/android.js @@ -81,6 +81,26 @@ function buildAndroidBridgeBootstrap() { downloadBlob(base64, mimeType, fileName) { downloads.push({ base64, mimeType, fileName }); }, + pickFiles(mode, requestId) { + const handler = window.__bdsNativeFilePicker; + setTimeout(function () { + let detail; + if (typeof handler === "function") { + try { + detail = handler(mode); + } catch (err) { + detail = { error: String(err), files: [] }; + } + } else { + detail = { error: "cancelled", files: [] }; + } + window.dispatchEvent( + new CustomEvent("__bds_native_files_picked_" + requestId, { + detail, + }), + ); + }, 10); + }, }; })(); `; diff --git a/tests/integration/platform/android-file-picker.test.js b/tests/integration/platform/android-file-picker.test.js new file mode 100644 index 0000000..c08c463 --- /dev/null +++ b/tests/integration/platform/android-file-picker.test.js @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildFolderFileFromNative, + isNativeFilePickerAvailable, + nativePickFiles, +} from "../../../src/platform/android-file-picker.js"; + +function readBlobText(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); +} + +describe("isNativeFilePickerAvailable", () => { + afterEach(() => { + delete window.AndroidBridge; + }); + + it("returns false when AndroidBridge is absent", () => { + expect(isNativeFilePickerAvailable()).toBe(false); + }); + + it("returns false when AndroidBridge lacks pickFiles", () => { + window.AndroidBridge = { getStorage: () => null }; + expect(isNativeFilePickerAvailable()).toBe(false); + }); + + it("returns true when AndroidBridge.pickFiles is a function", () => { + window.AndroidBridge = { pickFiles: vi.fn() }; + expect(isNativeFilePickerAvailable()).toBe(true); + }); +}); + +describe("nativePickFiles", () => { + function installBridgeMock(handler) { + window.AndroidBridge = { + pickFiles: vi.fn((mode, requestId) => { + setTimeout(() => { + const result = handler(mode, requestId); + window.dispatchEvent( + new CustomEvent("__bds_native_files_picked_" + requestId, { + detail: result, + }), + ); + }, 0); + }), + }; + } + + afterEach(() => { + delete window.AndroidBridge; + }); + + it("rejects immediately when bridge is unavailable", async () => { + await expect(nativePickFiles("files")).rejects.toThrow( + "AndroidBridge.pickFiles not available", + ); + }); + + it("resolves with markdown files on success", async () => { + installBridgeMock(() => ({ + files: [{ name: "notes.md", content: "# Notes" }], + })); + + const result = await nativePickFiles("files"); + + expect(result.files).toEqual([{ name: "notes.md", content: "# Notes" }]); + }); + + it("resolves with cancelled true on user cancellation", async () => { + installBridgeMock(() => ({ error: "cancelled", files: [] })); + + const result = await nativePickFiles("files"); + + expect(result.cancelled).toBe(true); + expect(result.files).toHaveLength(0); + }); + + it("rejects on non-cancellation errors", async () => { + installBridgeMock(() => ({ error: "permission denied", files: [] })); + + await expect(nativePickFiles("files")).rejects.toThrow("permission denied"); + }); + + it("passes folder mode through to the bridge", async () => { + installBridgeMock(() => ({ + files: [{ name: "README.md", content: "# Project" }], + folderName: "repo", + })); + + const result = await nativePickFiles("folder"); + + expect(window.AndroidBridge.pickFiles).toHaveBeenCalledWith( + "folder", + expect.any(String), + ); + expect(result.folderName).toBe("repo"); + }); +}); + +describe("buildFolderFileFromNative", () => { + it("returns null for empty input", () => { + expect(buildFolderFileFromNative([], "repo")).toBeNull(); + expect(buildFolderFileFromNative(null, "repo")).toBeNull(); + }); + + it("builds a concatenated workspace file that includes markdown files", async () => { + const file = buildFolderFileFromNative( + [ + { name: "src/index.js", content: "console.log('hello');" }, + { name: "README.md", content: "# Project" }, + ], + "repo", + ); + + expect(file).toBeInstanceOf(File); + expect(file.name).toBe("repo_workspace.txt"); + + const text = await readBlobText(file); + expect(text).toContain("Directory Tree:"); + expect(text).toContain("README.md"); + expect(text).toContain("--- [FILE: README.md] ---"); + expect(text).toContain("# Project"); + }); +});