diff --git a/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/build.gradle.kts b/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/build.gradle.kts
new file mode 100644
index 0000000000..8618c67e1a
--- /dev/null
+++ b/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/build.gradle.kts
@@ -0,0 +1,3 @@
+dependencies {
+ compileOnly(libs.annotation)
+}
diff --git a/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/src/main/AndroidManifest.xml b/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/src/main/java/app/revanced/extension/all/misc/directory/documentsprovider/InternalDataDocumentsProvider.java b/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/src/main/java/app/revanced/extension/all/misc/directory/documentsprovider/InternalDataDocumentsProvider.java
new file mode 100644
index 0000000000..cd4577c76e
--- /dev/null
+++ b/extensions/all/misc/directory/documentsprovider/export-internal-data-documents-provider/src/main/java/app/revanced/extension/all/misc/directory/documentsprovider/InternalDataDocumentsProvider.java
@@ -0,0 +1,334 @@
+package app.revanced.extension.all.misc.directory.documentsprovider;
+
+import android.content.Context;
+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;
+import android.provider.DocumentsProvider;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.StructStat;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Objects;
+
+/**
+ * A DocumentsProvider that allows access to the app's internal data directory.
+ */
+public class InternalDataDocumentsProvider extends DocumentsProvider {
+ private static final String[] rootColumns =
+ {"root_id", "mime_types", "flags", "icon", "title", "summary", "document_id"};
+ private static final String[] directoryColumns =
+ {"document_id", "mime_type", "_display_name", "last_modified", "flags",
+ "_size", "full_path", "lstat_info"};
+ private static final int S_IFLNK = 0x8000;
+
+ private String packageName;
+ private File dataDirectory;
+
+ /**
+ * Recursively delete a file or directory and all its children.
+ *
+ * @param root The file or directory to delete.
+ * @return True if the file or directory and all its children were successfully deleted.
+ */
+ private static boolean deleteRecursively(File root) {
+ // If root is a directory, delete all children first
+ if (root.isDirectory()) {
+ try {
+ // Only delete recursively if the directory is not a symlink
+ if ((Os.lstat(root.getPath()).st_mode & S_IFLNK) != S_IFLNK) {
+ File[] files = root.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ if (!deleteRecursively(file)) {
+ return false;
+ }
+ }
+ }
+ }
+ } catch (ErrnoException e) {
+ Log.e("InternalDocumentsProvider", "Failed to lstat " + root.getPath(), e);
+ }
+ }
+
+ // Delete file or empty directory
+ return root.delete();
+ }
+
+ /**
+ * Resolve the MIME type of a file based on its extension.
+ *
+ * @param file The file to resolve the MIME type for.
+ * @return The MIME type of the file.
+ */
+ private static String resolveMimeType(File file) {
+ if (file.isDirectory()) {
+ return DocumentsContract.Document.MIME_TYPE_DIR;
+ }
+
+ String name = file.getName();
+ int indexOfExtDot = name.lastIndexOf('.');
+ if (indexOfExtDot < 0) {
+ // No extension
+ return "application/octet-stream";
+ }
+
+ String extension = name.substring(indexOfExtDot + 1).toLowerCase();
+ String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ return mimeType != null ? mimeType : "application/octet-stream";
+ }
+
+ @Override
+ public final boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public final void attachInfo(Context context, ProviderInfo providerInfo) {
+ super.attachInfo(context, providerInfo);
+
+ this.packageName = context.getPackageName();
+ this.dataDirectory = context.getFilesDir().getParentFile();
+ }
+
+ @Override
+ public final String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException {
+ File directory = resolveDocumentId(parentDocumentId);
+ File file = new File(directory, displayName);
+
+ // If file already exists, append a number to the name
+ int i = 2;
+ while (file.exists()) {
+ file = new File(directory, displayName + " (" + i + ")");
+ i++;
+ }
+
+ try {
+ // Create the file or directory
+ if (mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR) ? file.mkdir() : file.createNewFile()) {
+ // Return the document ID of the new entity
+ if (!parentDocumentId.endsWith("/")) {
+ parentDocumentId = parentDocumentId + "/";
+ }
+ return parentDocumentId + file.getName();
+ }
+ } catch (IOException e) {
+ // Do nothing. We are throwing a FileNotFoundException later if the file could not be created.
+ }
+ throw new FileNotFoundException("Failed to create document in " + parentDocumentId + " with name " + displayName);
+ }
+
+ @Override
+ public final void deleteDocument(String documentId) throws FileNotFoundException {
+ File file = resolveDocumentId(documentId);
+ if (!deleteRecursively(file)) {
+ throw new FileNotFoundException("Failed to delete document " + documentId);
+ }
+ }
+
+ @Override
+ public final String getDocumentType(String documentId) throws FileNotFoundException {
+ return resolveMimeType(resolveDocumentId(documentId));
+ }
+
+ @Override
+ public final boolean isChildDocument(String parentDocumentId, String documentId) {
+ return documentId.startsWith(parentDocumentId);
+ }
+
+ @Override
+ public final String moveDocument(String sourceDocumentId, String sourceParentDocumentId, String targetParentDocumentId) throws FileNotFoundException {
+ File source = resolveDocumentId(sourceDocumentId);
+ File dest = resolveDocumentId(targetParentDocumentId);
+
+ File file = new File(dest, source.getName());
+ if (!file.exists() && source.renameTo(file)) {
+ // Return the new document ID
+ if (targetParentDocumentId.endsWith("/")) {
+ return targetParentDocumentId + file.getName();
+ }
+ return targetParentDocumentId + "/" + file.getName();
+ }
+
+ throw new FileNotFoundException("Failed to move document from " + sourceDocumentId + " to " + targetParentDocumentId);
+ }
+
+ @Override
+ public final ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) throws FileNotFoundException {
+ File file = resolveDocumentId(documentId);
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode));
+ }
+
+ @Override
+ public final Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException {
+ if (parentDocumentId.endsWith("/")) {
+ parentDocumentId = parentDocumentId.substring(0, parentDocumentId.length() - 1);
+ }
+
+ if (projection == null) {
+ projection = directoryColumns;
+ }
+
+ MatrixCursor cursor = new MatrixCursor(projection);
+ File children = resolveDocumentId(parentDocumentId);
+
+ // Collect all children
+ File[] files = children.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ addRowForDocument(cursor, parentDocumentId + "/" + file.getName(), file);
+ }
+ }
+ return cursor;
+ }
+
+ @Override
+ public final Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
+ if (projection == null) {
+ projection = directoryColumns;
+ }
+
+ MatrixCursor cursor = new MatrixCursor(projection);
+ addRowForDocument(cursor, documentId, null);
+ return cursor;
+ }
+
+ @Override
+ public final Cursor queryRoots(String[] projection) {
+ ApplicationInfo info = Objects.requireNonNull(getContext()).getApplicationInfo();
+ String appName = info.loadLabel(getContext().getPackageManager()).toString();
+
+ if (projection == null) {
+ projection = rootColumns;
+ }
+
+ MatrixCursor cursor = new MatrixCursor(projection);
+ MatrixCursor.RowBuilder row = cursor.newRow();
+ row.add(DocumentsContract.Root.COLUMN_ROOT_ID, this.packageName);
+ row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, this.packageName);
+ row.add(DocumentsContract.Root.COLUMN_SUMMARY, this.packageName);
+ row.add(DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.FLAG_LOCAL_ONLY |
+ DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD);
+ row.add(DocumentsContract.Root.COLUMN_TITLE, appName);
+ row.add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*");
+ row.add(DocumentsContract.Root.COLUMN_ICON, info.icon);
+ return cursor;
+ }
+
+ @Override
+ public final void removeDocument(String documentId, String parentDocumentId) throws FileNotFoundException {
+ deleteDocument(documentId);
+ }
+
+ @Override
+ public final String renameDocument(String documentId, String displayName) throws FileNotFoundException {
+ File file = resolveDocumentId(documentId);
+ if (!file.renameTo(new File(file.getParentFile(), displayName))) {
+ throw new FileNotFoundException("Failed to rename document from " + documentId + " to " + displayName);
+ }
+
+ // Return the new document ID
+ return documentId.substring(0, documentId.lastIndexOf('/', documentId.length() - 2)) + "/" + displayName;
+ }
+
+ /**
+ * Resolve a file instance for a given document ID.
+ *
+ * @param fullContentPath The document ID to resolve.
+ * @return File object for the given document ID.
+ * @throws FileNotFoundException If the document ID is invalid or the file does not exist.
+ */
+ private File resolveDocumentId(String fullContentPath) throws FileNotFoundException {
+ if (!fullContentPath.startsWith(this.packageName)) {
+ throw new FileNotFoundException(fullContentPath + " not found");
+ }
+ String path = fullContentPath.substring(this.packageName.length());
+
+ // Resolve the relative path within /data/data/{PKG}
+ File file;
+ if (path.equals("/") || path.isEmpty()) {
+ file = this.dataDirectory;
+ } else {
+ // Remove leading slash
+ String relativePath = path.substring(1);
+ file = new File(this.dataDirectory, relativePath);
+ }
+
+ if (!file.exists()) {
+ throw new FileNotFoundException(fullContentPath + " not found");
+ }
+ return file;
+ }
+
+ /**
+ * Add a row containing all file properties to a MatrixCursor for a given document ID.
+ *
+ * @param cursor The cursor to add the row to.
+ * @param documentId The document ID to add the row for.
+ * @param file The file to add the row for. If null, the file will be resolved from the document ID.
+ * @throws FileNotFoundException If the file does not exist.
+ */
+ private void addRowForDocument(MatrixCursor cursor, String documentId, File file) throws FileNotFoundException {
+ if (file == null) {
+ file = resolveDocumentId(documentId);
+ }
+
+ int flags = 0;
+ if (file.isDirectory()) {
+ // Prefer list view for directories
+ flags = flags | DocumentsContract.Document.FLAG_DIR_PREFERS_LAST_MODIFIED;
+ }
+
+ if (file.canWrite()) {
+ if (file.isDirectory()) {
+ flags = flags | DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE;
+ }
+
+ flags = flags | DocumentsContract.Document.FLAG_SUPPORTS_WRITE |
+ DocumentsContract.Document.FLAG_SUPPORTS_DELETE |
+ DocumentsContract.Document.FLAG_SUPPORTS_RENAME |
+ DocumentsContract.Document.FLAG_SUPPORTS_MOVE;
+ }
+
+ MatrixCursor.RowBuilder row = cursor.newRow();
+ row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId);
+ row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.getName());
+ row.add(DocumentsContract.Document.COLUMN_SIZE, file.length());
+ row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, resolveMimeType(file));
+ row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified());
+ row.add(DocumentsContract.Document.COLUMN_FLAGS, flags);
+
+ // Custom columns
+ row.add("full_path", file.getAbsolutePath());
+
+ // Add lstat column
+ String path = file.getPath();
+ try {
+ StringBuilder sb = new StringBuilder();
+ StructStat lstat = Os.lstat(path);
+ sb.append(lstat.st_mode);
+ sb.append(";");
+ sb.append(lstat.st_uid);
+ sb.append(";");
+ sb.append(lstat.st_gid);
+ // Append symlink target if it is a symlink
+ if ((lstat.st_mode & S_IFLNK) == S_IFLNK) {
+ sb.append(";");
+ sb.append(Os.readlink(path));
+ }
+ row.add("lstat_info", sb.toString());
+ } catch (Exception ex) {
+ Log.e("InternalDocumentsProvider", "Failed to get lstat info for " + path, ex);
+ }
+ }
+}
diff --git a/patches/api/patches.api b/patches/api/patches.api
index 36a925df87..5f7861ff2f 100644
--- a/patches/api/patches.api
+++ b/patches/api/patches.api
@@ -60,6 +60,10 @@ public final class app/revanced/patches/all/misc/directory/ChangeDataDirectoryLo
public static final fun getChangeDataDirectoryLocationPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}
+public final class app/revanced/patches/all/misc/directory/documentsprovider/ExportInternalDataDocumentsProviderPatchKt {
+ public static final fun getExportInternalDataDocumentsProviderPatch ()Lapp/revanced/patcher/patch/ResourcePatch;
+}
+
public final class app/revanced/patches/all/misc/hex/HexPatchKt {
public static final fun getHexPatch ()Lapp/revanced/patcher/patch/RawResourcePatch;
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt
index 7256d1edb3..8046c11fc3 100644
--- a/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt
@@ -1,58 +1,19 @@
package app.revanced.patches.all.misc.directory
-import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
-import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
import app.revanced.patcher.patch.bytecodePatch
-import app.revanced.patches.all.misc.transformation.transformInstructionsPatch
-import app.revanced.util.getReference
-import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c
-import com.android.tools.smali.dexlib2.iface.reference.MethodReference
-import com.android.tools.smali.dexlib2.immutable.reference.ImmutableMethodReference
-import com.android.tools.smali.dexlib2.util.MethodUtil
+import app.revanced.patches.all.misc.directory.documentsprovider.exportInternalDataDocumentsProviderPatch
@Suppress("unused")
+@Deprecated(
+ "Superseded by internalDataDocumentsProviderPatch",
+ ReplaceWith("internalDataDocumentsProviderPatch"),
+)
val changeDataDirectoryLocationPatch = bytecodePatch(
- name = "Change data directory location",
+ // name = "Change data directory location",
description = "Changes the data directory in the application from " +
"the app internal storage directory to /sdcard/android/data accessible by root-less devices." +
"Using this patch can cause unexpected issues with some apps.",
use = false,
) {
- dependsOn(
- transformInstructionsPatch(
- filterMap = filter@{ _, _, instruction, instructionIndex ->
- val reference = instruction.getReference() ?: return@filter null
-
- if (!MethodUtil.methodSignaturesMatch(reference, MethodCall.GetDir.reference)) {
- return@filter null
- }
-
- return@filter instructionIndex
- },
- transform = { method, index ->
- val getDirInstruction = method.getInstruction(index)
- val contextRegister = getDirInstruction.registerC
- val dataRegister = getDirInstruction.registerD
-
- method.replaceInstruction(
- index,
- "invoke-virtual { v$contextRegister, v$dataRegister }, " +
- "Landroid/content/Context;->getExternalFilesDir(Ljava/lang/String;)Ljava/io/File;",
- )
- },
- ),
- )
-}
-
-private enum class MethodCall(
- val reference: MethodReference,
-) {
- GetDir(
- ImmutableMethodReference(
- "Landroid/content/Context;",
- "getDir",
- listOf("Ljava/lang/String;", "I"),
- "Ljava/io/File;",
- ),
- ),
+ dependsOn(exportInternalDataDocumentsProviderPatch)
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/documentsprovider/ExportInternalDataDocumentsProviderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/documentsprovider/ExportInternalDataDocumentsProviderPatch.kt
new file mode 100644
index 0000000000..1f2a2d53c4
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/documentsprovider/ExportInternalDataDocumentsProviderPatch.kt
@@ -0,0 +1,58 @@
+package app.revanced.patches.all.misc.directory.documentsprovider
+
+import app.revanced.patcher.patch.bytecodePatch
+import app.revanced.patcher.patch.resourcePatch
+import app.revanced.util.asSequence
+import app.revanced.util.getNode
+
+@Suppress("unused")
+val exportInternalDataDocumentsProviderPatch = resourcePatch(
+ name = "Export internal data documents provider",
+ description = "Exports a documents provider that grants access to the internal data directory of this app " +
+ "to file managers and other apps that support the Storage Access Framework.",
+ use = false,
+) {
+ dependsOn(
+ bytecodePatch {
+ extendWith("extensions/all/misc/directory/export-internal-data-documents-provider.rve")
+ },
+ )
+
+ execute {
+ val documentsProviderClass =
+ "app.revanced.extension.all.misc.directory.documentsprovider.InternalDataDocumentsProvider"
+
+ document("AndroidManifest.xml").use { document ->
+ // Check if the provider is already declared
+ if (document.getElementsByTagName("provider")
+ .asSequence()
+ .any { it.attributes.getNamedItem("android:name")?.nodeValue == documentsProviderClass }
+ ) {
+ return@execute
+ }
+
+ val authority =
+ document.getNode("manifest").attributes.getNamedItem("package").let {
+ // Select a URI authority name that is unique to the current app
+ "${it.nodeValue}.$documentsProviderClass"
+ }
+
+ // Register the documents provider
+ with(document.getNode("application")) {
+ document.createElement("provider").apply {
+ setAttribute("android:name", documentsProviderClass)
+ setAttribute("android:authorities", authority)
+ setAttribute("android:exported", "true")
+ setAttribute("android:grantUriPermissions", "true")
+ setAttribute("android:permission", "android.permission.MANAGE_DOCUMENTS")
+
+ document.createElement("intent-filter").apply {
+ document.createElement("action").apply {
+ setAttribute("android:name", "android.content.action.DOCUMENTS_PROVIDER")
+ }.let(this::appendChild)
+ }.let(this::appendChild)
+ }.let(this::appendChild)
+ }
+ }
+ }
+}