fix(mobile): resolve document providers once per selection (#13147)

* fix(mobile): resolve each document provider once per selection

Signed-off-by: jabrailkhalil <[email protected]>

* docs(mobile): mark the file-selection spike follow-up as implemented

Signed-off-by: jabrailkhalil <[email protected]>

* test(android): distinguish file picker provider authorities

* test(android): accept granted documents from multiple authorities

---------

Signed-off-by: jabrailkhalil <[email protected]>
This commit is contained in:
Jabrail authored and GitHub committed 2026-10-06 16:22:24 +00:00
1 parent fc42ab9f7c
commit 07e905aa4b
6 files changed
+97 -10

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ Status: development-only spike under review, following [issue #11704](https://gi
This document records the Phase 1 baseline. The [Phase 2 connection-profile design](mobile-connection-profiles.md) supersedes the single-profile and plaintext native-storage sections below; other production prerequisites still apply.
Scoped native document selection is now implemented. The [native file-selection design](mobile-file-selection.md) supersedes the file-chooser follow-up below.
## Problem and Goals
Use the existing daemon-served Web Shell in an Android WebView, with no second native session UI and no locally bundled H5. Establish a buildable native bootstrap and a precise origin boundary before adding production credentials or background connectivity.
@@ -6,6 +6,8 @@
本文记录 Phase 1 基线。[Phase 2 连接配置设计](mobile-connection-profiles.zh-CN.md) 取代下文关于单配置和原生明文存储的说明,其他生产前置条件仍然适用。
受限的原生文档选择现已实现。[原生文件选择设计](mobile-file-selection.zh-CN.md) 取代下文关于文件选择器的后续工作说明。
## 问题与目标
在 Android WebView 中使用 daemon 已提供的 Web Shell,不创建第二套原生会话 UI,也不本地打包 H5。在添加生产凭据和后台连接前,建立可构建的原生启动流程和准确的 origin 边界。
@@ -6,7 +6,7 @@
<application>
<provider
android:name="com.qwen.mobileshell.FilePickerFixtureProvider"
android:authorities="com.qwen.mobileshell.test.picker"
android:authorities="com.qwen.mobileshell.test.picker;com.qwen.mobileshell.test.picker.second"
android:exported="true"
android:grantUriPermissions="true"
android:readPermission="com.qwen.mobileshell.test.READ_PICKER"
@@ -8,6 +8,7 @@ import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Process
import android.webkit.ValueCallback
import android.webkit.WebChromeClient.FileChooserParams
@@ -28,11 +29,11 @@ class FilePickerDeviceTest {
if (fixtureUsed) context.contentResolver.call(FilePickerFixtureProvider.BASE_URI, "reset", null, null)
}
private fun document(index: Int = 0, granted: Boolean = true): Uri {
private fun document(index: Int = 0, granted: Boolean = true, base: Uri = FilePickerFixtureProvider.BASE_URI): Uri {
if (!fixtureUsed) context.contentResolver.call(FilePickerFixtureProvider.BASE_URI, "reset", null, null)
fixtureUsed = true
val uri = FilePickerFixtureProvider.uri(index)
if (granted) context.contentResolver.call(FilePickerFixtureProvider.BASE_URI, "grant", index.toString(), null)
val uri = FilePickerFixtureProvider.uri(base, index)
if (granted) context.contentResolver.call(base, "grant", index.toString(), Bundle().apply { putString("authority", base.authority) })
val provider = requireNotNull(context.packageManager.resolveContentProvider(uri.authority!!, 0))
assertNotEquals("Fixture must be owned by a different UID", context.applicationInfo.uid, provider.applicationInfo.uid)
assertEquals("Fixture URI grant", if (granted) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED,
@@ -66,6 +67,22 @@ class FilePickerDeviceTest {
override fun createIntent() = Intent()
}
private class CountingContext(base: Context) : ContextWrapper(base) {
var providerManagerReads = 0
var permissionChecks = 0
// The picker obtains PackageManager only when resolving a provider.
override fun getPackageManager(): PackageManager {
providerManagerReads++
return super.getPackageManager()
}
override fun checkUriPermission(uri: Uri, pid: Int, uid: Int, flags: Int): Int {
permissionChecks++
return super.checkUriPermission(uri, pid, uid, flags)
}
}
@Test fun intentUsesReadOnlyDocumentsAndPreservesMultipleMimeHints() {
var launched: Intent? = null
val picker = NativeFilePicker(context) { launched = it }
@@ -146,6 +163,62 @@ class FilePickerDeviceTest {
assertNull(delivered(selection(uris.take(100)).setData(uris.last())))
}
@Test fun sameProviderIsResolvedOncePerSelectionAndEveryGrantIsChecked() {
val uris = (0 until 100).map { document(it) }
val countingContext = CountingContext(context)
val picker = NativeFilePicker(countingContext) { }
repeat(2) { index ->
var calls = 0
picker.open(Params(FileChooserParams.MODE_OPEN_MULTIPLE), { true }) {
assertArrayEquals(uris.toTypedArray(), it)
calls++
}
picker.result(Activity.RESULT_OK, selection(uris))
assertEquals(1, calls)
assertEquals(index + 1, countingContext.providerManagerReads)
assertEquals((index + 1) * uris.size, countingContext.permissionChecks)
}
}
@Test fun cachedProviderDoesNotAllowAnotherDocumentWithoutItsOwnGrant() {
val uris = listOf(document(0), document(1, granted = false))
val countingContext = CountingContext(context)
assertNull(delivered(selection(uris), pickerContext = countingContext))
assertEquals(1, countingContext.providerManagerReads)
assertEquals(2, countingContext.permissionChecks)
}
@Test fun secondAuthorityInOneSelectionIsResolvedSeparately() {
val uris = listOf(document(), android.provider.Settings.System.CONTENT_URI)
val countingContext = CountingContext(context)
assertNull(delivered(selection(uris), pickerContext = countingContext))
assertEquals(2, countingContext.providerManagerReads)
}
@Test fun multipleGrantedAuthoritiesPreserveOrderAndResolveEachProviderOnce() {
val uris = listOf(
document(0), document(0, base = FilePickerFixtureProvider.SECOND_BASE_URI),
document(1), document(1, base = FilePickerFixtureProvider.SECOND_BASE_URI),
)
val countingContext = CountingContext(context)
val actual = delivered(selection(uris), pickerContext = countingContext)
assertArrayEquals(uris.toTypedArray(), actual)
assertEquals(2, countingContext.providerManagerReads)
assertEquals(uris.size, countingContext.permissionChecks)
actual!!.forEach { uri ->
val bytes = context.contentResolver.openInputStream(uri)!!.use { it.readBytes() }
assertArrayEquals(FilePickerFixtureProvider.contents(uri.lastPathSegment!!.toInt()), bytes)
}
}
@Test fun mixedAuthoritiesStillRequireSeparateGrantsForTheSameDocumentIndex() {
val uris = listOf(document(0), document(0, granted = false, base = FilePickerFixtureProvider.SECOND_BASE_URI))
val countingContext = CountingContext(context)
assertNull(delivered(selection(uris), pickerContext = countingContext))
assertEquals(2, countingContext.providerManagerReads)
assertEquals(2, countingContext.permissionChecks)
}
@Test fun unsupportedModeAndStaleDocumentCancelWithoutLaunching() {
val calls = mutableListOf<Array<Uri>?>()
val picker = NativeFilePicker(context) { fail("Must not launch") }
@@ -20,6 +20,7 @@ import java.nio.charset.StandardCharsets;
public class FilePickerFixtureProvider extends ContentProvider {
private static final String TARGET = "com.qwen.mobileshell";
public static final Uri BASE_URI = Uri.parse("content://com.qwen.mobileshell.test.picker");
public static final Uri SECOND_BASE_URI = Uri.parse("content://com.qwen.mobileshell.test.picker.second");
@Override public boolean onCreate() { return true; }
@@ -36,10 +37,13 @@ public class FilePickerFixtureProvider extends ContentProvider {
long identity = Binder.clearCallingIdentity();
try {
if ("grant".equals(method)) {
owner.grantUriPermission(TARGET, uri(Integer.parseInt(arg)), Intent.FLAG_GRANT_READ_URI_PERMISSION);
Uri base = extras == null ? BASE_URI : Uri.parse("content://" + extras.getString("authority"));
owner.grantUriPermission(TARGET, uri(base, Integer.parseInt(arg)), Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else if ("reset".equals(method)) {
for (int index = 0; index <= 100; index++) {
owner.revokeUriPermission(TARGET, uri(index), Intent.FLAG_GRANT_READ_URI_PERMISSION);
for (Uri base : new Uri[] {BASE_URI, SECOND_BASE_URI}) {
owner.revokeUriPermission(TARGET, uri(base, index), Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
File file = new File(owner.getCacheDir(), "picker-fixture-" + index + ".txt");
if (file.exists() && !file.delete()) throw new IllegalStateException("Cannot remove fixture file");
}
@@ -55,7 +59,8 @@ public class FilePickerFixtureProvider extends ContentProvider {
@Override public ParcelFileDescriptor openFile(Uri selected, String mode) throws FileNotFoundException {
if (!"r".equals(mode)) throw new IllegalArgumentException("Fixture is read-only");
int index = Integer.parseInt(selected.getLastPathSegment());
if (!selected.equals(uri(index))) throw new IllegalArgumentException("Unknown fixture URI");
Uri base = selected.buildUpon().path(null).build();
if (!selected.equals(uri(base, index))) throw new IllegalArgumentException("Unknown fixture URI");
Context owner = getContext();
if (owner == null) throw new IllegalStateException("Fixture provider is not attached");
File file = new File(owner.getCacheDir(), "picker-fixture-" + index + ".txt");
@@ -75,9 +80,10 @@ public class FilePickerFixtureProvider extends ContentProvider {
@Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { throw new UnsupportedOperationException(); }
@Override public int delete(Uri uri, String selection, String[] selectionArgs) { throw new UnsupportedOperationException(); }
public static Uri uri(int index) {
public static Uri uri(Uri base, int index) {
if (!BASE_URI.equals(base) && !SECOND_BASE_URI.equals(base)) throw new IllegalArgumentException("Unknown fixture authority");
if (index < 0 || index > 100) throw new IllegalArgumentException("Unknown fixture index");
return BASE_URI.buildUpon().appendPath(Integer.toString(index)).build();
return base.buildUpon().appendPath(Integer.toString(index)).build();
}
public static byte[] contents(int index) {
@@ -5,6 +5,7 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.net.Uri
import android.os.Process
import android.webkit.MimeTypeMap
@@ -90,9 +91,12 @@ internal class NativeFilePicker(
}
}.distinct()
if (uris.isEmpty() || uris.size > 100 || (!multiple && uris.size != 1)) return null
val providers = mutableMapOf<String, ProviderInfo>()
for (uri in uris) {
if (uri.scheme != "content" || uri.authority.isNullOrBlank()) return null
val provider = context.packageManager.resolveContentProvider(uri.authority!!, 0) ?: return null
val provider = providers.getOrPut(uri.authority!!) {
context.packageManager.resolveContentProvider(uri.authority!!, 0) ?: return null
}
if (provider.applicationInfo.uid == context.applicationInfo.uid) return null
if (context.checkUriPermission(uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_READ_URI_PERMISSION) != PackageManager.PERMISSION_GRANTED) return null
}