From 2c4ee3d169b273282791175bccca123afdbc1feb Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 17 Aug 2026 13:01:47 +0200 Subject: [PATCH] Expose a patched app's private data to file managers without root A module -- or the user -- often needs to reach a patched app's private data, and only that app's own UID can. A patch can now inject a DocumentsProvider that runs inside the app and serves its data directory through the Storage Access Framework, so any file manager that speaks SAF can browse it with no root. It is off by default and clearly an expert option, since it widens what can reach the app's data. The provider is declared in the manifest as a per-package authority behind the MANAGE_DOCUMENTS permission -- the one the system Documents UI holds -- so only the system can bind it and access reaches other apps only through the user granting a document or tree. Its exported and grantUriPermissions attributes must be written as real booleans: the platform reads them with TypedArray.getBoolean, which returns the default for a string, so a boolean spelled "true" silently un-exports the provider. The vendored ManifestEditor could only add providers as string pairs; core is bumped to the version that emits provider attributes with their real types. The harder half is that the platform instantiates a manifest-declared provider from the app's own class loader, which holds only the original apk and its splits -- never the loader's in-memory dex, where the provider class lives -- so installContentProviders failed the class at startup. The class cannot simply be grafted onto the app loader's dex path: ART binds an in-memory dex to the class-loader context it was defined under, and defining the class a second time elsewhere is rejected. Instead a filtering loader is spliced in as the app loader's parent; standard delegation consults it on every lookup, but it answers with exactly one class -- the provider, loaded by the loader that already owns it -- and defers everything else, so the app's own classes still resolve from its own dexes unchanged. The choice is recorded in the patched app's config, like the added permissions, so a re-patch that recovers only the original apks keeps it. The CLI gains --documents-provider; the manager's advanced sheet gains a toggle, and the permission editor moves below it -- its chip list is the tallest control in the group, so it sits last rather than pushing the compact toggles down. Strings are translated across every shipped locale. Closes #65 --- core | 2 +- .../main/java/org/lsposed/lspatch/Patcher.kt | 1 + .../lspatch/data/model/PatchRequest.kt | 3 + .../lspatch/data/repository/PatchReport.kt | 1 + .../lsposed/lspatch/ui/page/NewPatchScreen.kt | 14 + .../org/lsposed/lspatch/ui/page/PatchEntry.kt | 1 + .../lspatch/ui/viewmodel/NewPatchViewModel.kt | 3 + manager/src/main/res/values-ar/strings.xml | 2 + manager/src/main/res/values-de/strings.xml | 2 + manager/src/main/res/values-es/strings.xml | 2 + manager/src/main/res/values-fa/strings.xml | 2 + manager/src/main/res/values-fr/strings.xml | 2 + manager/src/main/res/values-in/strings.xml | 2 + manager/src/main/res/values-it/strings.xml | 2 + manager/src/main/res/values-iw/strings.xml | 2 + manager/src/main/res/values-ja/strings.xml | 2 + manager/src/main/res/values-ko/strings.xml | 2 + manager/src/main/res/values-pl/strings.xml | 2 + .../src/main/res/values-pt-rBR/strings.xml | 2 + manager/src/main/res/values-ru/strings.xml | 2 + manager/src/main/res/values-tr/strings.xml | 2 + manager/src/main/res/values-uk/strings.xml | 2 + manager/src/main/res/values-vi/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + manager/src/main/res/values/strings.xml | 2 + .../lspatch/loader/LSPApplication.java | 47 ++++ .../loader/LSPatchDocumentsProvider.java | 243 ++++++++++++++++++ .../java/org/lsposed/patch/ApkPatcher.java | 43 +++- .../main/java/org/lsposed/patch/LSPatch.java | 4 + .../org/lsposed/patch/ManifestOverrides.java | 17 +- .../org/lsposed/lspatch/share/Constants.java | 7 + .../lsposed/lspatch/share/PatchConfig.java | 11 +- 33 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPatchDocumentsProvider.java diff --git a/core b/core index 9d6d44c1b..35b510cb1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9d6d44c1b0967965c40ec663b369abd34492496f +Subproject commit 35b510cb1be569c253ab21a1f0e1bf7e380ab225 diff --git a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt index 2ce11fc02..d52c581bd 100644 --- a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt +++ b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt @@ -43,6 +43,7 @@ object Patcher { .extractNativeLibs(if (extractNativeLibs) true else null) .usesCleartextTraffic(if (usesCleartextTraffic) true else null) .permissions(addedPermissions) + .injectDocumentsProvider(injectDocumentsProvider) .build() ) .keystore( diff --git a/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt b/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt index 3442fed64..aed721519 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt @@ -88,6 +88,9 @@ data class PatchRequest( // Extra uses-permission names, already canonical. Recorded in the patched app's config, unlike // the overrides above, so a re-patch that only recovers the original apks still keeps them. val addedPermissions: List = emptyList(), + // Inject a DocumentsProvider exposing the app's private data. Recorded in the config for the same + // reason as the permissions above. + val injectDocumentsProvider: Boolean = false, val origin: PatchOrigin = PatchOrigin.New, ) { val packageName: String get() = target.packageName diff --git a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt index 0068c15f8..c3f5cf5d2 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt @@ -63,6 +63,7 @@ object PatchReport { add("Version code ${request.versionCodeOverride?.toString() ?: "app's own"}") add("Inject dex ${request.injectDex}") add("Added perms ${if (request.addedPermissions.isEmpty()) "none" else request.addedPermissions.joinToString(", ")}") + add("Docs provider ${request.injectDocumentsProvider}") add("Keystore ${if (MyKeyStore.useDefault) "built-in" else "custom (${MyKeyStore.file.name})"}") add("Verbose ${Configs.detailPatchLogs}") add("") diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt index b43edaeac..c83e0333c 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt @@ -343,6 +343,7 @@ fun NewPatchScreen( onCleartext = viewModel::setUsesCleartextTraffic, onAddPermission = viewModel::addPermission, onRemovePermission = viewModel::removePermission, + onInjectDocumentsProvider = viewModel::setInjectDocumentsProvider, ) else -> Column( Modifier @@ -390,6 +391,7 @@ private fun ConfigureBody( onCleartext: (Boolean) -> Unit, onAddPermission: (String) -> Unit, onRemovePermission: (String) -> Unit, + onInjectDocumentsProvider: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() val snackbarHost = LocalSnackbarHost.current @@ -590,6 +592,15 @@ private fun ConfigureBody( checked = request.usesCleartextTraffic, onCheckedChange = onCleartext, ) + ToggleRow( + title = stringResource(R.string.patch_manifest_documents_provider), + icon = Icons.Rounded.FolderOpen, + subtitle = stringResource(R.string.patch_manifest_documents_provider_desc), + checked = request.injectDocumentsProvider, + onCheckedChange = onInjectDocumentsProvider, + ) + // Last of the manifest controls: its chip list and field are the tallest thing here, + // so it sits below the compact toggles rather than pushing them down the screen. PermissionEditor( added = request.addedPermissions, onAdd = onAddPermission, @@ -770,6 +781,9 @@ private fun advancedChips(request: PatchRequest): List = buildLi ) ) } + if (request.injectDocumentsProvider) { + add(OptionChipData(stringResource(R.string.patch_manifest_documents_provider), true, Icons.Rounded.FolderOpen)) + } if (!MyKeyStore.useDefault) { add(OptionChipData(stringResource(R.string.settings_keystore_custom), true, Icons.Outlined.Ballot)) } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt index baf78443a..04f6b0f94 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt @@ -75,6 +75,7 @@ suspend fun rePatchRequestFor( versionCodeOverride = config?.versionCode, // Recovered from the recorded config, not the original apks -- those never carried them. addedPermissions = config?.addedPermissions?.toList() ?: emptyList(), + injectDocumentsProvider = config?.injectDocumentsProvider ?: false, sigBypassLevel = config?.sigBypassLevel ?: 2, injectDex = config?.injectDex ?: false, modules = modules ?: embedded, diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt index ce31d49b2..ecb8b4270 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt @@ -112,6 +112,9 @@ class NewPatchViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { fun removePermission(name: String) = mutate { it.copy(addedPermissions = it.addedPermissions - name) } + fun setInjectDocumentsProvider(value: Boolean) = + mutate { it.copy(injectDocumentsProvider = value) } + /** * Adds modules to the set, keeping what is already there. * diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index 6e9989bb3..b08214982 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -148,6 +148,8 @@ إضافة إزالة أُضيف %1$d + إتاحة البيانات الخاصة + أضف موفّرًا يتيح لمدير الملفات تصفح مجلد البيانات الخاصة بالتطبيق عبر منتقي ملفات النظام، دون الحاجة إلى روت. يوسّع ما يمكنه الوصول إلى بيانات التطبيق — اتركه معطّلًا ما لم تحتَجه. اختر تطبيقًا لترقيعه جارٍ تحميل التطبيقات المثبَّتة… مُصحَّح بالفعل diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml index 377c0766f..86c73d6bd 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -148,6 +148,8 @@ Hinzufügen Entfernen %1$d hinzugefügt + Private Daten freigeben + Fügt einen Provider hinzu, damit ein Dateimanager den privaten Datenordner der App über die System-Dateiauswahl durchsuchen kann – ohne Root. Erweitert, wer an die App-Daten gelangt – lass es aus, wenn du es nicht brauchst. Wähle eine App zum Patchen Installierte Apps werden geladen… Bereits gepatcht diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index 6eb0bdcdc..38edba758 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -148,6 +148,8 @@ Añadir Quitar %1$d añadidos + Exponer datos privados + Añade un proveedor para que un gestor de archivos pueda explorar la carpeta de datos privados de la app desde el selector de archivos del sistema, sin root. Amplía lo que puede acceder a los datos de la app: déjalo desactivado salvo que lo necesites. Elige una aplicación para parchear Cargando las aplicaciones instaladas… Ya parcheada diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index 043ead5e4..b054303c6 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -148,6 +148,8 @@ افزودن حذف %1$d افزوده شد + نمایش داده‌های خصوصی + یک ارائه‌دهنده اضافه می‌کند تا یک مدیر فایل بتواند پوشهٔ دادهٔ خصوصی برنامه را از طریق انتخابگر فایل سیستم مرور کند، بدون نیاز به روت. دامنهٔ دسترسی به دادهٔ برنامه را گسترده می‌کند — اگر لازم ندارید خاموش بگذارید. برنامه‌ای برای پچ انتخاب کنید در حال بارگذاری برنامه‌های نصب‌شده… قبلاً وصله شده diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml index 138d132be..7935b7da2 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -148,6 +148,8 @@ Ajouter Retirer %1$d ajoutée(s) + Exposer les données privées + Ajoute un fournisseur pour qu\'un gestionnaire de fichiers puisse parcourir le dossier de données privées de l\'application via le sélecteur de fichiers du système, sans root. Élargit ce qui peut accéder aux données de l\'application — à laisser désactivé sauf besoin. Choisir une application à patcher Chargement des applications installées… Déjà patchée diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 2be407661..5667891d7 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -148,6 +148,8 @@ Tambah Hapus %1$d ditambahkan + Ekspos data pribadi + Menambahkan penyedia agar pengelola berkas bisa menjelajahi folder data pribadi aplikasi lewat pemilih berkas sistem, tanpa root. Memperluas apa yang bisa menjangkau data aplikasi — biarkan mati kecuali Anda memerlukannya. Pilih aplikasi untuk ditambal Memuat aplikasi terpasang… Sudah ditambal diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index c2921a4fa..f7453ee46 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -148,6 +148,8 @@ Aggiungi Rimuovi %1$d aggiunte + Esponi i dati privati + Aggiunge un provider così un file manager può sfogliare la cartella dei dati privati dell\'app dal selettore file di sistema, senza root. Amplia ciò che può raggiungere i dati dell\'app: lascialo disattivato se non ti serve. Scegli un\'app a cui applicare la patch Caricamento delle app installate… Già patchata diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index 64f99295d..18a04be49 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -148,6 +148,8 @@ הוסף הסר %1$d נוספו + חשיפת נתונים פרטיים + מוסיף ספק כדי שמנהל קבצים יוכל לעיין בתיקיית הנתונים הפרטיים של האפליקציה דרך בורר הקבצים של המערכת, בלי רוט. מרחיב את מה שיכול להגיע לנתוני האפליקציה — השאר כבוי אלא אם צריך. בחרו אפליקציה להחלת טלאי טוען אפליקציות מותקנות… כבר עבר טלאי diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml index 197fef982..27b77c3bc 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -148,6 +148,8 @@ 追加 削除 %1$d 件追加 + 非公開データを公開 + プロバイダーを追加し、ルート権限なしでシステムのファイル選択画面からアプリの非公開データフォルダーをファイルマネージャーで閲覧できるようにします。アプリのデータにアクセスできる範囲が広がるため、必要でなければオフのままにしてください。 パッチするアプリを選択 インストール済みのアプリを読み込み中… パッチ済み diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml index fdd47fd6d..a46995b63 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -148,6 +148,8 @@ 추가 제거 %1$d개 추가됨 + 비공개 데이터 노출 + 프로바이더를 추가해 루트 없이 시스템 파일 선택기를 통해 파일 관리자가 앱의 비공개 데이터 폴더를 탐색할 수 있게 합니다. 앱 데이터에 접근할 수 있는 범위가 넓어지므로 필요하지 않으면 꺼 두세요. 패치할 앱 선택 설치된 앱을 불러오는 중… 이미 패치됨 diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml index 64dc62145..c1158b8ba 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -148,6 +148,8 @@ Dodaj Usuń Dodano %1$d + Udostępnij dane prywatne + Dodaje dostawcę, aby menedżer plików mógł przeglądać folder prywatnych danych aplikacji przez systemowy wybór plików, bez roota. Poszerza to, co może sięgnąć do danych aplikacji — zostaw wyłączone, jeśli nie potrzebujesz. Wybierz aplikację do zmodyfikowania Wczytywanie zainstalowanych aplikacji… Już załatane diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml index 9a5586e9f..f77463c37 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -148,6 +148,8 @@ Adicionar Remover %1$d adicionadas + Expor dados privados + Adiciona um provedor para que um gerenciador de arquivos possa navegar na pasta de dados privados do app pelo seletor de arquivos do sistema, sem root. Amplia o que pode acessar os dados do app — deixe desligado, a menos que precise. Escolha um app para aplicar o patch Carregando os apps instalados… Já com patch diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index 4fa05a40d..3b712e40c 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -148,6 +148,8 @@ Добавить Удалить Добавлено: %1$d + Открыть личные данные + Добавляет провайдера, чтобы файловый менеджер мог просматривать папку личных данных приложения через системный выбор файлов, без root. Расширяет круг того, что может добраться до данных приложения — оставьте выключенным, если не нужно. Выберите приложение для патча Загрузка установленных приложений… Уже пропатчено diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml index 680419f14..d15d18ea4 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -148,6 +148,8 @@ Ekle Kaldır %1$d eklendi + Özel verileri aç + Bir sağlayıcı ekler; böylece bir dosya yöneticisi, root olmadan sistem dosya seçici üzerinden uygulamanın özel veri klasörüne göz atabilir. Uygulama verilerine erişebilecekleri genişletir — gerekmiyorsa kapalı bırakın. Yamalanacak uygulamayı seçin Kurulu uygulamalar yükleniyor… Zaten yamalı diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index b2c04a801..c493701f5 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -148,6 +148,8 @@ Додати Вилучити Додано: %1$d + Відкрити приватні дані + Додає постачальника, щоб файловий менеджер міг переглядати теку приватних даних застосунку через системний вибір файлів, без root. Розширює те, що може дістатися до даних застосунку — лишіть вимкненим, якщо не потрібно. Виберіть застосунок для патчу Завантаження встановлених застосунків… Уже пропатчено diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml index b220ba0c3..888851ae1 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -148,6 +148,8 @@ Thêm Xóa Đã thêm %1$d + Hiển thị dữ liệu riêng + Thêm một provider để trình quản lý tệp có thể duyệt thư mục dữ liệu riêng của ứng dụng qua bộ chọn tệp hệ thống, không cần root. Mở rộng những gì có thể tiếp cận dữ liệu ứng dụng — hãy tắt trừ khi bạn cần. Chọn ứng dụng để vá Đang tải ứng dụng đã cài… Đã vá diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 20ec9aeaa..5f5393b76 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -245,4 +245,6 @@ 添加 移除 已添加 %1$d 个 + 暴露私有数据 + 添加一个提供程序,让文件管理器无需 root 即可通过系统文件选择器浏览应用的私有数据目录。这会扩大可访问应用数据的范围——若无需要请保持关闭。 diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index fad450da7..5dbb44a52 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -148,6 +148,8 @@ 新增 移除 已新增 %1$d 個 + 公開私有資料 + 新增一個提供者,讓檔案管理器無需 root 即可透過系統檔案選擇器瀏覽應用程式的私有資料目錄。這會擴大可存取應用程式資料的範圍——若無需要請保持關閉。 選擇要修補的應用程式 正在載入已安裝的應用程式… 已修補 diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index fe812b592..2bedb5b84 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -97,6 +97,8 @@ Add Remove %1$d added + Expose private data + Add a provider so a file manager can browse the app\'s private data folder through the system file picker, no root needed. Widens what can reach the app\'s data — leave off unless you need it. Patching… Patch failed Something went wrong. Copy the log to see the details. diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java b/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java index a5fe9b3e8..39a02b5ff 100644 --- a/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java +++ b/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java @@ -137,9 +137,56 @@ public static void onLoad() throws RemoteException, IOException { switchAllClassLoader(); + // Only after the app class loader exists, and before installContentProviders runs on return + // from makeApplication, so the injected DocumentsProvider is resolvable when the platform + // instantiates it. + if (config.optBoolean("injectDocumentsProvider")) { + bridgeDocumentsProviderClass(); + } + Log.i(TAG, "LSPatch bootstrap completed"); } + /** The loader class the app's class loader must be taught to resolve for issue #65. */ + private static final String DOCUMENTS_PROVIDER_CLASS = "org.lsposed.lspatch.loader.LSPatchDocumentsProvider"; + + /** + * Makes the injected DocumentsProvider resolvable by the app's class loader. + * + * A manifest-declared component is instantiated by the platform from the app's class loader, + * which holds only the original apk and its splits; the provider class lives in the loader's own + * in-memory dex, which that loader never consults, so installContentProviders fails it at startup. + * The class cannot simply be grafted onto the app loader's dex path: ART binds an in-memory dex to + * the class-loader context it was defined under, so defining the class a second time elsewhere is + * rejected (the "ClassLoaderContext mismatch" the log shows). + * + * Instead, splice a filtering loader in front of the app loader as its parent. Standard delegation + * consults it on every lookup, but it answers with exactly one class -- the provider, loaded by the + * loader that already owns it -- and defers everything else to the app loader's real parent. The + * app's own classes are still found in its own dexes exactly as before; only the one class the app + * never had now resolves. The provider depends only on the framework, so nothing else is pulled in. + */ + private static void bridgeDocumentsProviderClass() { + try { + ClassLoader appClassLoader = appLoadedApk.getClassLoader(); + final ClassLoader loaderClassLoader = LSPApplication.class.getClassLoader(); + ClassLoader realParent = appClassLoader.getParent(); + ClassLoader bridge = new ClassLoader(realParent) { + @Override + protected Class findClass(String name) throws ClassNotFoundException { + if (DOCUMENTS_PROVIDER_CLASS.equals(name)) { + return loaderClassLoader.loadClass(name); + } + throw new ClassNotFoundException(name); + } + }; + XposedHelpers.setObjectField(appClassLoader, "parent", bridge); + Log.i(TAG, "Documents provider: bridged into the app class loader"); + } catch (Throwable t) { + Log.e(TAG, "Failed to expose the documents provider to the app class loader", t); + } + } + /** * Loads the modern modules and delivers embed-mode service binders for the already-built target * {@code LoadedApk}. diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPatchDocumentsProvider.java b/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPatchDocumentsProvider.java new file mode 100644 index 000000000..728f04426 --- /dev/null +++ b/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPatchDocumentsProvider.java @@ -0,0 +1,243 @@ +package org.lsposed.lspatch.loader; + +import android.content.pm.ApplicationInfo; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * Exposes the patched app's own private data directory ({@code /data/data/}) through the + * Storage Access Framework, so any file manager that speaks SAF can browse it with no root. + * + *

The provider runs inside the patched app's process and UID, which is the whole trick: only that + * UID (or root) may read those files, and this is the one component that runs there and can hand them + * out. It is declared in the manifest at patch time behind {@code android.permission.MANAGE_DOCUMENTS} + * -- the platform-signature permission the system Documents UI holds -- so nothing but the system can + * bind it, and access reaches other apps only through the user granting a document or tree.

+ * + *

Document ids are absolute file paths. Every path handed back in is re-checked to sit inside the + * exported root before it is touched, so a crafted id cannot walk out of the app's own data.

+ */ +public class LSPatchDocumentsProvider extends DocumentsProvider { + + private static final String ROOT_ID = "lspatch"; + + private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{ + Root.COLUMN_ROOT_ID, + Root.COLUMN_FLAGS, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_ICON, + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{ + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + }; + + /** The one directory this provider is allowed to reach, canonicalised once. */ + private File root; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public void attachInfo(android.content.Context context, ProviderInfo info) { + super.attachInfo(context, info); + ApplicationInfo appInfo = context.getApplicationInfo(); + try { + root = new File(appInfo.dataDir).getCanonicalFile(); + } catch (IOException e) { + root = new File(appInfo.dataDir).getAbsoluteFile(); + } + } + + @Override + public Cursor queryRoots(String[] projection) { + MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); + ApplicationInfo appInfo = getContext().getApplicationInfo(); + CharSequence label = appInfo.loadLabel(getContext().getPackageManager()); + + MatrixCursor.RowBuilder row = result.newRow(); + row.add(Root.COLUMN_ROOT_ID, ROOT_ID); + row.add(Root.COLUMN_FLAGS, + Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD | Root.FLAG_LOCAL_ONLY); + row.add(Root.COLUMN_TITLE, label != null ? label.toString() : getContext().getPackageName()); + row.add(Root.COLUMN_SUMMARY, getContext().getPackageName()); + row.add(Root.COLUMN_DOCUMENT_ID, docIdForFile(root)); + // The app's own launcher icon, so the root is recognisable in the picker; 0 is a valid + // "no icon" the framework tolerates. + row.add(Root.COLUMN_ICON, appInfo.icon); + return result; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + addFileRow(result, fileForDocId(documentId)); + return result; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException { + MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + File parent = fileForDocId(parentDocumentId); + File[] children = parent.listFiles(); + if (children != null) { + for (File child : children) { + addFileRow(result, child); + } + } + return result; + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) + throws FileNotFoundException { + return ParcelFileDescriptor.open(fileForDocId(documentId), ParcelFileDescriptor.parseMode(mode)); + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) + throws FileNotFoundException { + File parent = fileForDocId(parentDocumentId); + File target = new File(parent, displayName); + try { + if (Document.MIME_TYPE_DIR.equals(mimeType)) { + if (!target.mkdir()) throw new IOException("Failed to mkdir " + target); + } else { + if (!target.createNewFile()) throw new IOException("Failed to create " + target); + } + } catch (IOException e) { + throw new FileNotFoundException("Failed to create document: " + e.getMessage()); + } + return docIdForFile(target); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + File file = fileForDocId(documentId); + if (!deleteRecursively(file)) { + throw new FileNotFoundException("Failed to delete " + documentId); + } + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + File file = fileForDocId(documentId); + File target = new File(file.getParentFile(), displayName); + if (!file.renameTo(target)) { + throw new FileNotFoundException("Failed to rename " + documentId); + } + // The id is the path, so a rename mints a new id; returning it tells the framework to + // re-point rather than keep the stale one. + return docIdForFile(target); + } + + @Override + public String getDocumentType(String documentId) throws FileNotFoundException { + return mimeTypeOf(fileForDocId(documentId)); + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + try { + String parent = fileForDocId(parentDocumentId).getCanonicalPath(); + String child = fileForDocId(documentId).getCanonicalPath(); + return child.startsWith(parent.endsWith("/") ? parent : parent + "/"); + } catch (IOException e) { + return false; + } + } + + private void addFileRow(MatrixCursor result, File file) { + int flags = 0; + if (file.isDirectory()) { + if (file.canWrite()) flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (file.canWrite()) { + flags |= Document.FLAG_SUPPORTS_WRITE; + } + if (file.canWrite()) { + flags |= Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME + | Document.FLAG_SUPPORTS_REMOVE; + } + + MatrixCursor.RowBuilder row = result.newRow(); + row.add(Document.COLUMN_DOCUMENT_ID, docIdForFile(file)); + // The exported root shows the app's label rather than the raw data-dir basename. + row.add(Document.COLUMN_DISPLAY_NAME, file.equals(root) ? rootDisplayName() : file.getName()); + row.add(Document.COLUMN_MIME_TYPE, mimeTypeOf(file)); + row.add(Document.COLUMN_SIZE, file.length()); + row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); + row.add(Document.COLUMN_FLAGS, flags); + } + + private String rootDisplayName() { + CharSequence label = getContext().getApplicationInfo().loadLabel(getContext().getPackageManager()); + return label != null ? label.toString() : getContext().getPackageName(); + } + + private String docIdForFile(File file) { + return file.getAbsolutePath(); + } + + /** + * Resolves a document id back to a file, refusing anything that would land outside the exported + * root -- a crafted {@code ../} id cannot reach another app's data or follow a symlink out. + */ + private File fileForDocId(String documentId) throws FileNotFoundException { + File file = new File(documentId); + try { + String canonical = file.getCanonicalPath(); + String base = root.getCanonicalPath(); + if (!canonical.equals(base) && !canonical.startsWith(base + "/")) { + throw new FileNotFoundException(documentId + " is outside the exported root"); + } + return file; + } catch (IOException e) { + throw new FileNotFoundException("Failed to resolve " + documentId + ": " + e.getMessage()); + } + } + + private static String mimeTypeOf(File file) { + if (file.isDirectory()) return Document.MIME_TYPE_DIR; + String name = file.getName(); + int dot = name.lastIndexOf('.'); + if (dot >= 0) { + String extension = name.substring(dot + 1).toLowerCase(java.util.Locale.ROOT); + String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mime != null) return mime; + } + return "application/octet-stream"; + } + + private static boolean deleteRecursively(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + if (!deleteRecursively(child)) return false; + } + } + } + return file.delete(); + } +} diff --git a/patch/src/main/java/org/lsposed/patch/ApkPatcher.java b/patch/src/main/java/org/lsposed/patch/ApkPatcher.java index 1e658d28e..6511049b1 100644 --- a/patch/src/main/java/org/lsposed/patch/ApkPatcher.java +++ b/patch/src/main/java/org/lsposed/patch/ApkPatcher.java @@ -185,10 +185,11 @@ private void patchOne(File srcApkFile, File outputFile, int index, int total) th originalSignature, manifest.appComponentFactory, spec.injectDex(), - spec.manifestOverrides().addedPermissions.toArray(new String[0])); + spec.manifestOverrides().addedPermissions.toArray(new String[0]), + spec.manifestOverrides().injectDocumentsProvider); byte[] configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8); - rewriteManifest(srcZFile, dstZFile, configBytes, manifest.minSdkVersion, index, total); + rewriteManifest(srcZFile, dstZFile, configBytes, manifest.packageName, manifest.minSdkVersion, index, total); injectLoader(srcZFile, dstZFile, index, total); if (!spec.useManager()) { addLoaderPayload(dstZFile); @@ -287,13 +288,14 @@ private void packSplit(NestedZip srcZFile, ZFile dstZFile, int index, int total) * reads it at startup without a package manager. */ private void rewriteManifest( - NestedZip srcZFile, ZFile dstZFile, byte[] configBytes, int minSdkVersion, int index, int total) + NestedZip srcZFile, ZFile dstZFile, byte[] configBytes, String packageName, int minSdkVersion, + int index, int total) throws IOException { logger.stage(Logger.Stage.REWRITING, index, total); logger.i("Patching apk..."); String metadata = Base64.getEncoder().encodeToString(configBytes); StoredEntry manifestEntry = Objects.requireNonNull(srcZFile.get(ANDROID_MANIFEST_XML)); - try (InputStream is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata, minSdkVersion))) { + try (InputStream is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata, packageName, minSdkVersion))) { dstZFile.add(ANDROID_MANIFEST_XML, is); } catch (IOException e) { throw new PatchException("Error when modifying manifest", e); @@ -424,7 +426,8 @@ private static boolean isSignatureEntry(String name) { && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith(".RSA")); } - private byte[] modifyManifestFile(InputStream is, String metadata, int minSdkVersion) throws IOException { + private byte[] modifyManifestFile(InputStream is, String metadata, String packageName, int minSdkVersion) + throws IOException { ModificationProperty property = new ModificationProperty(); // The loader is built against 28; an app declaring less would be refused the APIs it uses. @@ -434,7 +437,7 @@ private byte[] modifyManifestFile(InputStream is, String metadata, int minSdkVer property.addApplicationAttribute(new AttributeItem(NodeValue.Application.DEBUGGABLE, spec.debuggable())); property.addApplicationAttribute(new AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)); property.addMetaData(new ModificationProperty.MetaData("lspatch", metadata)); - applyManifestOverrides(property); + applyManifestOverrides(property, packageName); // TODO: replace query_all with queries -> manager if (spec.useManager()) { property.addUsesPermission("android.permission.QUERY_ALL_PACKAGES"); @@ -457,7 +460,7 @@ private byte[] modifyManifestFile(InputStream is, String metadata, int minSdkVer * override changes the compatibility behaviours the platform applies; the two booleans flip * install-time and network policy an app otherwise fixes against a module's needs. */ - private void applyManifestOverrides(ModificationProperty property) { + private void applyManifestOverrides(ModificationProperty property, String packageName) { ManifestOverrides o = spec.manifestOverrides(); if (o.isEmpty()) return; if (o.versionCode != null) { @@ -486,5 +489,31 @@ private void applyManifestOverrides(ModificationProperty property) { logger.i("Add permission: " + permission); property.addUsesPermission(permission); } + if (o.injectDocumentsProvider) { + addDocumentsProvider(property, packageName); + } + } + + /** + * Declares the loader's {@code DocumentsProvider} so the app's private data shows up in the + * system file picker. + * + * The authority is per-package so two patched apps never collide. {@code MANAGE_DOCUMENTS} is the + * platform-signature permission the Documents UI holds, so gating the provider behind it means + * only the system can bind it -- access still flows through the user granting a tree, not through + * any app reaching the authority directly. {@code exported} and {@code grantUriPermissions} are + * passed as real booleans; a string {@code "true"} would be read back as false and quietly + * un-export the provider. + */ + private void addDocumentsProvider(ModificationProperty property, String packageName) { + String authority = packageName + Constants.DOCUMENTS_PROVIDER_AUTHORITY_SUFFIX; + logger.i("Add documents provider: " + authority); + List attributes = new ArrayList<>(); + attributes.add(new AttributeItem(NodeValue.Application.NAME, Constants.DOCUMENTS_PROVIDER_CLASS)); + attributes.add(new AttributeItem(NodeValue.Application.Provider.AUTHORITIES, authority)); + attributes.add(new AttributeItem("exported", Boolean.TRUE)); + attributes.add(new AttributeItem("grantUriPermissions", Boolean.TRUE)); + attributes.add(new AttributeItem(NodeValue.Application.Component.PERMISSION, "android.permission.MANAGE_DOCUMENTS")); + property.addProvider(attributes, "android.content.action.DOCUMENTS_PROVIDER"); } } diff --git a/patch/src/main/java/org/lsposed/patch/LSPatch.java b/patch/src/main/java/org/lsposed/patch/LSPatch.java index 52d907dd8..486e499a5 100644 --- a/patch/src/main/java/org/lsposed/patch/LSPatch.java +++ b/patch/src/main/java/org/lsposed/patch/LSPatch.java @@ -77,6 +77,9 @@ public class LSPatch { @Parameter(names = {"--add-permission"}, description = "Add a to the manifest (repeatable). A bare name is prefixed with android.permission.") private List addedPermissions = new ArrayList<>(); + @Parameter(names = {"--documents-provider"}, description = "Inject a DocumentsProvider exposing the app's private data to the system file picker") + private boolean injectDocumentsProvider = false; + private final JCommander jCommander; public LSPatch(String... args) { @@ -131,6 +134,7 @@ public PatchSpec toSpec() throws PatchException { .permissions(addedPermissions.stream() .map(ManifestOverrides::normalizePermission) .collect(Collectors.toList())) + .injectDocumentsProvider(injectDocumentsProvider) .build(); return PatchSpec.builder() .apks(apkPaths.stream().map(File::new).collect(Collectors.toList())) diff --git a/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java b/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java index d0c89f849..a40cc3945 100644 --- a/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java +++ b/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java @@ -59,6 +59,14 @@ public final class ManifestOverrides { */ public final List addedPermissions; + /** + * Whether to inject a {@code DocumentsProvider} that exposes the app's private data directory + * through the Storage Access Framework, so an external file manager can browse it without root. + * The provider class ships in the loader; this only decides whether the {@code } is + * declared. Off by default -- it widens the app's data-isolation surface. + */ + public final boolean injectDocumentsProvider; + private ManifestOverrides(Builder b) { this.versionCode = b.versionCode; this.label = b.label; @@ -66,13 +74,14 @@ private ManifestOverrides(Builder b) { this.extractNativeLibs = b.extractNativeLibs; this.usesCleartextTraffic = b.usesCleartextTraffic; this.addedPermissions = Collections.unmodifiableList(new ArrayList<>(b.addedPermissions)); + this.injectDocumentsProvider = b.injectDocumentsProvider; } /** True when nothing is overridden, so the patcher can skip the work entirely. */ public boolean isEmpty() { return versionCode == null && label == null && targetSdkVersion == null && extractNativeLibs == null && usesCleartextTraffic == null - && addedPermissions.isEmpty(); + && addedPermissions.isEmpty() && !injectDocumentsProvider; } /** @@ -111,6 +120,7 @@ public static final class Builder { // A set behind an ordered facade: duplicates a caller passes are collapsed here, while the // order the user added them in is what the report and the re-patch see. private final LinkedHashSet addedPermissions = new LinkedHashSet<>(); + private boolean injectDocumentsProvider; public Builder versionCode(Integer versionCode) { this.versionCode = versionCode; @@ -152,6 +162,11 @@ public Builder permissions(List permissions) { return this; } + public Builder injectDocumentsProvider(boolean injectDocumentsProvider) { + this.injectDocumentsProvider = injectDocumentsProvider; + return this; + } + public ManifestOverrides build() { return new ManifestOverrides(this); } diff --git a/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java b/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java index f8a8f7f09..46520af1a 100644 --- a/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java +++ b/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java @@ -10,6 +10,13 @@ public class Constants { final static public String PATCH_FILE_SUFFIX = "-lspatched.apk"; final static public String PROXY_APP_COMPONENT_FACTORY = "org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub"; + /** + * The {@code DocumentsProvider} baked into the loader, and the suffix its authority is built + * from: {@code .lspatch.documents}. Per-package so two patched apps never collide, + * and shared here so the patcher writes the same authority the provider is registered under. + */ + final static public String DOCUMENTS_PROVIDER_CLASS = "org.lsposed.lspatch.loader.LSPatchDocumentsProvider"; + final static public String DOCUMENTS_PROVIDER_AUTHORITY_SUFFIX = ".lspatch.documents"; final static public String MANAGER_PACKAGE_NAME = "org.lsposed.lspatch"; final static public int MIN_ROLLING_VERSION_CODE = 348; diff --git a/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java b/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java index f775cc5e8..6ed1ead07 100644 --- a/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java +++ b/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java @@ -25,6 +25,13 @@ public class PatchConfig { * loader update would silently drop the permissions a module depends on. */ public final String[] addedPermissions; + + /** + * Whether a {@code DocumentsProvider} exposing the app's private data was injected. Recorded for + * the same reason as {@link #addedPermissions}: a re-patch recovers the original apks, which + * never carried it, so without this the option would silently turn itself off on a loader update. + */ + public final boolean injectDocumentsProvider; public final LSPConfig lspConfig; public PatchConfig( @@ -35,7 +42,8 @@ public PatchConfig( String originalSignature, String appComponentFactory, boolean injectDex, - String[] addedPermissions + String[] addedPermissions, + boolean injectDocumentsProvider ) { this.useManager = useManager; this.debuggable = debuggable; @@ -45,6 +53,7 @@ public PatchConfig( this.appComponentFactory = appComponentFactory; this.injectDex = injectDex; this.addedPermissions = addedPermissions; + this.injectDocumentsProvider = injectDocumentsProvider; this.lspConfig = LSPConfig.instance; } }