diff --git a/docs/design/mobile-android-shell.md b/docs/design/mobile-android-shell.md index 8d25a47fea4..54b8150636b 100644 --- a/docs/design/mobile-android-shell.md +++ b/docs/design/mobile-android-shell.md @@ -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. diff --git a/docs/design/mobile-android-shell.zh-CN.md b/docs/design/mobile-android-shell.zh-CN.md index 861cc653e4f..54e23d931ea 100644 --- a/docs/design/mobile-android-shell.zh-CN.md +++ b/docs/design/mobile-android-shell.zh-CN.md @@ -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 边界。 diff --git a/packages/mobile-shell/app/src/androidTest/AndroidManifest.xml b/packages/mobile-shell/app/src/androidTest/AndroidManifest.xml index d99cebcb0ef..b59603c4d7d 100644 --- a/packages/mobile-shell/app/src/androidTest/AndroidManifest.xml +++ b/packages/mobile-shell/app/src/androidTest/AndroidManifest.xml @@ -6,7 +6,7 @@ + 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?>() val picker = NativeFilePicker(context) { fail("Must not launch") } diff --git a/packages/mobile-shell/app/src/androidTest/java/com/qwen/mobileshell/FilePickerFixtureProvider.java b/packages/mobile-shell/app/src/androidTest/java/com/qwen/mobileshell/FilePickerFixtureProvider.java index 5e0a91103a0..0480a8b53d9 100644 --- a/packages/mobile-shell/app/src/androidTest/java/com/qwen/mobileshell/FilePickerFixtureProvider.java +++ b/packages/mobile-shell/app/src/androidTest/java/com/qwen/mobileshell/FilePickerFixtureProvider.java @@ -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) { diff --git a/packages/mobile-shell/app/src/main/java/com/qwen/mobileshell/NativeFilePicker.kt b/packages/mobile-shell/app/src/main/java/com/qwen/mobileshell/NativeFilePicker.kt index af404574b24..14f1d000e81 100644 --- a/packages/mobile-shell/app/src/main/java/com/qwen/mobileshell/NativeFilePicker.kt +++ b/packages/mobile-shell/app/src/main/java/com/qwen/mobileshell/NativeFilePicker.kt @@ -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() 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 }