mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
fc42ab9f7c
commit
07e905aa4b
6 files changed
+97
-10
No files matched your search
@@ -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"
|
||||
|
||||
+76
-3
@@ -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") }
|
||||
|
||||
+11
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user