From 18c3703abc74b54293bf12eb81c158fe07724a1c Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 24 Oct 2026 15:29:28 +0800 Subject: [PATCH 1/2] Share root-only files via cache copy instead of widening FileProvider Follow-up to the FileProvider root-path / pre-grant hardening. With root-path narrowed to storage/ and mnt/media_rw/, files elsewhere (eg. /data, root-only files) no longer get a provider URI, so opening, sharing, picking and installing them would throw. Changes: - Add FileUtils.getShareableUri(): returns the provider URI when the path is covered, otherwise copies the file into cache/open_with// and returns the URI of the copy. Root-only files are read through SuFileInputStream and written by the app itself, so the copy carries the app's own SELinux label/ownership (no root cp/chmod). Copies older than 24 hours are purged. - Add getShareableUriAsync() (RxJava Maybe): copying runs on the IO scheduler, shows the "Copying" toast, result delivered on main thread. - Add ShareCopyException; copy failures show copy_error toast, files that cannot be shared at all keep showing no_app_found. - Use the async helper in openFile, openWith(File), shareFiles, installApk and MainFragment.returnIntentResults (picker). - fromContentUri() maps storage_root/ -> /storage/ and media_rw_root/ -> /mnt/media_rw/, keeping legacy URI fallback; the database viewer path extra now uses it. - Tests in FileUtilsTest for URI mapping, cache copy, name collisions, async scheduling/toast and root copy failure cleanup. Grants stay READ-only everywhere, so external editors cannot write back to the original file; this is intentional (strictest, per the report). --- .../filesystem/files/FileUtils.java | 270 ++++++++++++++++-- .../ui/dialogs/OpenFileDialogFragment.kt | 12 - .../ui/fragments/MainFragment.java | 65 +++-- .../com/amaze/filemanager/utils/Utils.java | 4 +- app/src/main/res/xml/provider_paths.xml | 10 +- .../filesystem/files/FileUtilsTest.kt | 172 +++++++++++ .../amaze/filemanager/utils/UtilsTest.java | 2 +- 7 files changed, 470 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java b/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java index 6b6a6bec65..4f205c72f5 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java @@ -23,6 +23,12 @@ import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.CONTENT; import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; import java.text.ParsePosition; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -58,6 +64,7 @@ import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; import com.amaze.filemanager.ui.icons.MimeTypes; import com.amaze.filemanager.ui.theme.AppTheme; +import com.topjohnwu.superuser.io.SuFileInputStream; import com.amaze.filemanager.utils.DataUtils; import com.amaze.filemanager.utils.OTGUtil; import com.amaze.filemanager.utils.OnProgressUpdate; @@ -85,11 +92,16 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; import androidx.core.content.FileProvider; import androidx.core.util.Pair; import androidx.documentfile.provider.DocumentFile; import jcifs.smb.SmbFile; +import io.reactivex.Maybe; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; import kotlin.collections.ArraysKt; import net.schmizz.sshj.sftp.RemoteResourceInfo; import net.schmizz.sshj.sftp.SFTPClient; @@ -106,6 +118,9 @@ public class FileUtils { }; public static final String FILE_PROVIDER_PREFIX = "storage_root"; + public static final String FILE_PROVIDER_MEDIA_RW_PREFIX = "media_rw_root"; + public static final String OPEN_WITH_CACHE_DIR = "open_with"; + private static final long OPEN_WITH_CACHE_EXPIRY_MS = 24L * 60 * 60 * 1000; public static final String NOMEDIA_FILE = ".nomedia"; public static final String DUMMY_FILE = ".DummyFile"; @@ -317,7 +332,6 @@ protected void onPostExecute(String s) { public static void shareFiles( ArrayList files, Activity activity, AppTheme appTheme, int fab_skin) { - ArrayList uris = new ArrayList<>(); boolean isGenericFileType = false; String mime = @@ -326,7 +340,6 @@ public static void shareFiles( : null; for (File f : files) { - uris.add(FileProvider.getUriForFile(activity, activity.getPackageName(), f)); if (!isGenericFileType && (mime == null || !mime.equals(MimeTypes.getMimeType(f.getPath(), f.isDirectory())))) { isGenericFileType = true; @@ -334,12 +347,27 @@ public static void shareFiles( } if (isGenericFileType || mime == null) mime = MimeTypes.ALL_MIME_TYPES; - - try { - new ShareTask(activity, uris, appTheme, fab_skin).execute(mime); - } catch (Exception e) { - LOG.warn("failed to get share files", e); - } + final String finalMime = mime; + + Observable.fromIterable(files) + .concatMapMaybe(f -> getShareableUriAsync(activity, f)) + .toList() + .subscribe( + uris -> { + if (activity.isFinishing() || uris.isEmpty()) { + if (!activity.isFinishing()) { + Toast.makeText(activity, R.string.no_app_found, Toast.LENGTH_LONG).show(); + } + return; + } + try { + new ShareTask(activity, new ArrayList<>(uris), appTheme, fab_skin) + .execute(finalMime); + } catch (Exception e) { + LOG.warn("failed to get share files", e); + } + }, + e -> onShareCopyFailed(activity, e)); } public static float readableFileSizeFloat(long size) { @@ -380,20 +408,37 @@ public static void installApk( () -> installApk(f, permissionsActivity), true); } - Intent intent = new Intent(Intent.ACTION_VIEW); - String type = "application/vnd.android.package-archive"; + final String type = "application/vnd.android.package-archive"; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Uri downloadedApk = - FileProvider.getUriForFile( - permissionsActivity.getApplicationContext(), permissionsActivity.getPackageName(), f); - intent.setDataAndType(downloadedApk, type); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Files outside the FileProvider paths (eg. root-only) are copied to cache off the UI thread + getShareableUriAsync(permissionsActivity, f) + .subscribe( + downloadedApk -> { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(downloadedApk, type); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startInstallActivity(intent, permissionsActivity); + }, + e -> onShareCopyFailed(permissionsActivity, e), + () -> { + if (!permissionsActivity.isFinishing()) { + Toast.makeText( + permissionsActivity, R.string.failed_install_apk, Toast.LENGTH_SHORT) + .show(); + } + }); } else { + Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(Uri.fromFile(f), type); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startInstallActivity(intent, permissionsActivity); } + } + private static void startInstallActivity( + @NonNull Intent intent, @NonNull PermissionsActivity permissionsActivity) { + if (permissionsActivity.isFinishing()) return; try { permissionsActivity.startActivity(intent); } catch (Exception e) { @@ -445,8 +490,17 @@ private static void applyNewDocFlag(Intent i) { /** Method supports showing a UI to ask user to open a file without any extension/mime */ public static void openWith( final File f, final PreferenceActivity activity, final boolean useNewStack) { - openWith( - FileProvider.getUriForFile(activity, activity.getPackageName(), f), activity, useNewStack); + getShareableUriAsync(activity, f) + .subscribe( + uri -> { + if (!activity.isFinishing()) openWith(uri, activity, useNewStack); + }, + e -> onShareCopyFailed(activity, e), + () -> { + if (!activity.isFinishing()) { + Toast.makeText(activity, R.string.no_app_found, Toast.LENGTH_SHORT).show(); + } + }); } public static void openWith( @@ -498,10 +552,7 @@ public static void openWith( // to file's absolute path intent.putExtra( "path", - uri.getPath() - .substring( - uri.getPath().indexOf(FILE_PROVIDER_PREFIX) - 1, - FILE_PROVIDER_PREFIX.length() + 1)); + fromContentUri(uri).getAbsolutePath()); break; case 5: mimeType = MimeTypes.getMimeType(uri.getPath(), false); @@ -651,6 +702,134 @@ public static String[] getPathsInPath(String path) { } } + /** Thrown when copying a file into cache for sharing failed, eg. blocked by SELinux. */ + public static class ShareCopyException extends IOException { + public ShareCopyException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Gets a {@link FileProvider} URI for given file. Files outside the paths exposed by the provider + * (eg. files in /data, only accessible using root) are copied into the app's cache, and the URI + * of the copy is returned instead. + * + * @return URI, or null if the file cannot be exposed + */ + @Nullable + public static Uri getShareableUri(@NonNull Context context, @NonNull File file) { + try { + return getShareableUriOrThrow(context, file); + } catch (IOException e) { + LOG.warn("failed to copy {} to cache for sharing", file.getPath(), e); + return null; + } + } + + /** + * Same as {@link #getShareableUri(Context, File)}, but failures while copying are thrown as + * {@link ShareCopyException}, so callers can tell them apart from files that simply cannot be + * shared (null return). + */ + @Nullable + private static Uri getShareableUriOrThrow(@NonNull Context context, @NonNull File file) + throws IOException { + try { + return FileProvider.getUriForFile(context, context.getPackageName(), file); + } catch (IllegalArgumentException e) { + LOG.debug("{} is not covered by FileProvider paths, copying to cache", file.getPath()); + } + if (file.isDirectory()) { + return null; + } + + boolean readable = file.canRead(); + if (!readable + && !PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(PreferencesConstants.PREFERENCE_ROOTMODE, false)) { + return null; + } + + File dir = new File(context.getCacheDir(), OPEN_WITH_CACHE_DIR); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new ShareCopyException("cannot create " + dir, null); + } + deleteExpiredCopies(dir); + + // Each copy goes to its own directory, so files with the same name never overwrite each other + File copyDir = new File(dir, UUID.randomUUID().toString()); + if (!copyDir.mkdirs()) { + throw new ShareCopyException("cannot create " + copyDir, null); + } + File copy = new File(copyDir, file.getName()); + // Root reads the source and this app writes the copy itself, so the copy always carries the + // app's own SELinux label and ownership, unlike a copy made by "cp" running as root. + try (InputStream in = readable ? new FileInputStream(file) : SuFileInputStream.open(file); + OutputStream out = new FileOutputStream(copy)) { + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) > 0) { + out.write(buffer, 0, len); + } + } catch (IOException | RuntimeException e) { + copy.delete(); + copyDir.delete(); + throw new ShareCopyException("failed to copy " + file, e); + } + try { + return FileProvider.getUriForFile(context, context.getPackageName(), copy); + } catch (IllegalArgumentException e) { + copy.delete(); + copyDir.delete(); + throw new ShareCopyException("copy is not exposed by FileProvider: " + copy, e); + } + } + + private static void onShareCopyFailed(@NonNull Activity activity, @NonNull Throwable e) { + LOG.warn("failed to prepare file for sharing", e); + if (!activity.isFinishing()) { + Toast.makeText( + activity, + activity.getString(R.string.copy_error, activity.getString(R.string.copied)), + Toast.LENGTH_LONG) + .show(); + } + } + + private static void deleteExpiredCopies(@NonNull File dir) { + long expiry = System.currentTimeMillis() - OPEN_WITH_CACHE_EXPIRY_MS; + File[] stale = dir.listFiles(f -> f.lastModified() < expiry); + if (stale != null) { + for (File old : stale) { + File[] children = old.listFiles(); + if (children != null) { + for (File child : children) { + child.delete(); + } + } + old.delete(); + } + } + } + + /** + * Non-blocking version of {@link #getShareableUri(Context, File)}. Files covered by the provider + * paths emit immediately; copying to cache happens on an IO thread, and the result is delivered on + * the main thread, and a "copying" toast is shown on subscription. Completes empty if the file cannot be exposed. + */ + @NonNull + public static Maybe getShareableUriAsync(@NonNull Context context, @NonNull File file) { + try { + return Maybe.just(FileProvider.getUriForFile(context, context.getPackageName(), file)); + } catch (IllegalArgumentException e) { + return Maybe.fromCallable(() -> getShareableUriOrThrow(context, file)) + .doOnSubscribe( + d -> Toast.makeText(context, R.string.copying, Toast.LENGTH_SHORT).show()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + } + public static boolean canListFiles(File f) { return f.canRead() && f.isDirectory(); } @@ -695,12 +874,28 @@ private static void openFileDialogFragmentFor( @NonNull MainActivity mainActivity, @NonNull String mimeType, @NonNull Boolean useNewStack) { - OpenFileDialogFragment.Companion.openFileOrShow( - FileProvider.getUriForFile(mainActivity, mainActivity.getPackageName(), file), - mimeType, - useNewStack, - mainActivity, - false); + getShareableUriAsync(mainActivity, file) + .subscribe( + uri -> { + if (mainActivity.isFinishing()) return; + try { + OpenFileDialogFragment.Companion.openFileOrShow( + uri, mimeType, useNewStack, mainActivity, false); + } catch (Exception e) { + Toast.makeText( + mainActivity, + mainActivity.getString(R.string.no_app_found), + Toast.LENGTH_LONG) + .show(); + openWith(uri, mainActivity, useNewStack); + } + }, + e -> onShareCopyFailed(mainActivity, e), + () -> { + if (!mainActivity.isFinishing()) { + Toast.makeText(mainActivity, R.string.no_app_found, Toast.LENGTH_LONG).show(); + } + }); } private static void openFileDialogFragmentFor( @@ -1027,11 +1222,26 @@ public static File fromContentUri(@NonNull Uri uri) { if (!CONTENT.name().equalsIgnoreCase(uri.getScheme())) { LOG.warn("URI must start with content://. URI was [" + uri + "]"); } - File pathFile = new File(uri.getPath().substring(FILE_PROVIDER_PREFIX.length() + 1)); + String uriPath = uri.getPath(); + if (uriPath.startsWith("/" + FILE_PROVIDER_PREFIX + "/")) { + // root-path "storage/" is exposed as storage_root, so the "/storage" part is stripped + File pathFile = new File("/storage" + uriPath.substring(FILE_PROVIDER_PREFIX.length() + 1)); + if (!pathFile.exists()) { + // URIs generated before the provider paths were narrowed + File legacy = new File(uriPath.substring(FILE_PROVIDER_PREFIX.length() + 1)); + if (legacy.exists()) { + return legacy; + } + LOG.warn("Failed to navigate to the initial path: {}", pathFile.getPath()); + } + return pathFile; + } else if (uriPath.startsWith("/" + FILE_PROVIDER_MEDIA_RW_PREFIX + "/")) { + return new File( + "/mnt/media_rw" + uriPath.substring(FILE_PROVIDER_MEDIA_RW_PREFIX.length() + 1)); + } + File pathFile = new File(uriPath); if (!pathFile.exists()) { LOG.warn("Failed to navigate to the initial path: {}", pathFile.getPath()); - pathFile = new File(uri.getPath()); - LOG.warn("Attempting to navigate to the fallback path: {}", pathFile.getPath()); } return pathFile; } diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt index 72a063dbb6..a949a4cace 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt @@ -157,18 +157,6 @@ class OpenFileDialogFragment : BaseBottomSheetFragment(), AdjustListViewForTv= Build.VERSION_CODES.LOLLIPOP) { chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java index 8dddfa8579..4830947054 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java @@ -134,6 +134,9 @@ import jcifs.smb.SmbException; import jcifs.smb.SmbFile; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; import kotlin.collections.ArraysKt; import kotlin.collections.CollectionsKt; @@ -552,20 +555,53 @@ public void updateTabWithDb(Tab tab) { */ public void returnIntentResults(HybridFileParcelable[] baseFiles) { requireMainActivity().mReturnIntent = false; - HashMap resultUris = new HashMap<>(); - ArrayList failedPaths = new ArrayList<>(); - - for (HybridFileParcelable baseFile : baseFiles) { - @Nullable Uri resultUri = Utils.getUriForBaseFile(requireActivity(), baseFile); - if (resultUri != null) { - resultUris.put(baseFile, resultUri); - LOG.debug( - resultUri + "\t" + MimeTypes.getMimeType(baseFile.getPath(), baseFile.isDirectory())); - } else { - failedPaths.add(baseFile.getPath()); - } - } + final Context context = requireActivity(); + // Resolving URIs may copy files into cache, which must not block the UI thread + Single.fromCallable( + () -> { + HashMap uris = new HashMap<>(); + for (HybridFileParcelable baseFile : baseFiles) { + @Nullable Uri resultUri = Utils.getUriForBaseFile(context, baseFile); + if (resultUri != null) { + uris.put(baseFile, resultUri); + LOG.debug( + resultUri + + "\t" + + MimeTypes.getMimeType(baseFile.getPath(), baseFile.isDirectory())); + } else { + LOG.warn("Unable to get URI from baseFile {}", baseFile.getPath()); + } + } + return uris; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + uris -> { + if (uris.size() < baseFiles.length && getActivity() != null) { + Toast.makeText( + getActivity(), + getString(R.string.copy_error, getString(R.string.copied)), + Toast.LENGTH_LONG) + .show(); + } + deliverIntentResults(uris); + }, + e -> { + LOG.warn("Unable to get URIs from baseFiles", e); + if (getActivity() != null) { + Toast.makeText( + getActivity(), + getString(R.string.copy_error, getString(R.string.copied)), + Toast.LENGTH_LONG) + .show(); + getActivity().finish(); + } + }); + } + private void deliverIntentResults(HashMap resultUris) { + if (getActivity() == null) return; if (!resultUris.isEmpty()) { Intent intent = new Intent(); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); @@ -613,9 +649,6 @@ public void returnIntentResults(HybridFileParcelable[] baseFiles) { requireActivity().setResult(FragmentActivity.RESULT_OK, intent); } - if (!failedPaths.isEmpty()) { - LOG.warn("Unable to get URIs from baseFiles {}", failedPaths); - } requireActivity().finish(); } diff --git a/app/src/main/java/com/amaze/filemanager/utils/Utils.java b/app/src/main/java/com/amaze/filemanager/utils/Utils.java index 437360edef..edc9ba9185 100644 --- a/app/src/main/java/com/amaze/filemanager/utils/Utils.java +++ b/app/src/main/java/com/amaze/filemanager/utils/Utils.java @@ -33,6 +33,7 @@ import com.amaze.filemanager.R; import com.amaze.filemanager.adapters.data.LayoutElementParcelable; import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.FileUtils; import com.amaze.filemanager.ui.activities.MainActivity; import com.amaze.filemanager.ui.theme.AppTheme; import com.google.android.material.snackbar.Snackbar; @@ -262,8 +263,7 @@ public static Uri getUriForBaseFile( case FILE: case ROOT: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return FileProvider.getUriForFile( - context, context.getPackageName(), new File(baseFile.getPath())); + return FileUtils.getShareableUri(context, new File(baseFile.getPath())); } else { return Uri.fromFile(new File(baseFile.getPath())); } diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index f8cb599588..9584ba28ff 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -2,9 +2,11 @@ + - - \ No newline at end of file + + + diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt index 311913f1ac..96e524b4cc 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt @@ -23,17 +23,26 @@ package com.amaze.filemanager.filesystem.files import android.os.Build import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P +import android.net.Uri +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.filesystem.files.FileUtils.getPathsInPath import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers +import org.junit.After +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.io.File import java.util.TimeZone @RunWith(AndroidJUnit4::class) @@ -438,4 +447,167 @@ class FileUtilsTest { TimeZone.setDefault(systemTz) } + + /** FileProvider caches path strategies statically, but Robolectric cache dir differs per test */ + @Before + fun resetFileProviderCache() { + val field = androidx.core.content.FileProvider::class.java.getDeclaredField("sCache") + field.isAccessible = true + (field.get(null) as MutableMap<*, *>).clear() + } + + @After + fun resetRx() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + } + + private val context get() = ApplicationProvider.getApplicationContext() + + @Test + fun testFromContentUriStorageRoot() { + val uri = Uri.parse("content://com.amaze.filemanager/storage_root/emulated/0/Download/a.txt") + assertEquals("/storage/emulated/0/Download/a.txt", FileUtils.fromContentUri(uri).path) + } + + @Test + fun testFromContentUriMediaRw() { + val uri = Uri.parse("content://com.amaze.filemanager/media_rw_root/ABCD-1234/a.txt") + assertEquals("/mnt/media_rw/ABCD-1234/a.txt", FileUtils.fromContentUri(uri).path) + } + + @Test + fun testFromContentUriLegacyStorageRoot() { + val uri = Uri.parse("content://com.amaze.filemanager/storage_root/etc/hosts") + // old URI shape: /storage_root + absolute path + assertEquals("/etc/hosts", FileUtils.fromContentUri(uri).path) + } + + @Test + fun testGetShareableUriForCoveredPath() { + val uri = FileUtils.getShareableUri(context, File("/storage/emulated/0/test.txt")) + assertNotNull(uri) + assertEquals("/storage_root/emulated/0/test.txt", uri!!.path) + } + + @Test + fun testGetShareableUriCopiesUncoveredFile() { + val src = File.createTempFile("amaze", ".txt") + src.writeText("hello") + try { + val uri = FileUtils.getShareableUri(context, src) + assertNotNull(uri) + assertTrue(uri!!.path!!.startsWith("/open_with/")) + context.contentResolver.openInputStream(uri)!!.use { + assertEquals("hello", it.readBytes().toString(Charsets.UTF_8)) + } + } finally { + src.delete() + } + } + + @Test + fun testGetShareableUriRefusesUnreadableAndDirectories() { + assertNull(FileUtils.getShareableUri(context, File("/nonexistent/secret.txt"))) + val dir = java.nio.file.Files.createTempDirectory("amazedir").toFile() + try { + assertNull(FileUtils.getShareableUri(context, dir)) + } finally { + dir.delete() + } + } + + @Test + fun testBuildIntentDoesNotGrantToOtherApps() { + val uri = Uri.parse("content://${context.packageName}/storage_root/emulated/0/a.txt") + val intent = + com.amaze.filemanager.ui.dialogs.OpenFileDialogFragment.buildIntent( + context, + uri, + "text/plain", + false, + null, + null, + ) + assertEquals(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION, intent.flags and + (android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or + android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + assertFalse(intent.flags and android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) + } + + @Test + fun testGetShareableUriAsyncCoveredPathEmitsWithoutScheduling() { + val uri = + FileUtils.getShareableUriAsync(context, File("/storage/emulated/0/test.txt")) + .blockingGet() + assertNull(org.robolectric.shadows.ShadowToast.getLatestToast()) + assertEquals("/storage_root/emulated/0/test.txt", uri!!.path) + } + + @Test + fun testGetShareableUriAsyncCopiesOnIoScheduler() { + val ioThreads = mutableListOf() + RxJavaPlugins.setIoSchedulerHandler { + Schedulers.trampoline().also { ioThreads.add("io") } + } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + val src = File.createTempFile("amaze", ".txt") + src.writeText("async") + try { + val uri = FileUtils.getShareableUriAsync(context, src).blockingGet() + assertEquals(listOf("io"), ioThreads) + assertEquals( + context.getString(com.amaze.filemanager.R.string.copying), + org.robolectric.shadows.ShadowToast.getTextOfLatestToast(), + ) + context.contentResolver.openInputStream(uri!!)!!.use { + assertEquals("async", it.readBytes().toString(Charsets.UTF_8)) + } + } finally { + src.delete() + } + } + + @Test + fun testGetShareableUriAsyncCompletesEmptyWhenNotShareable() { + RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + assertNull( + FileUtils.getShareableUriAsync(context, File("/nonexistent/secret.txt")).blockingGet(), + ) + } + + @Test + fun testGetShareableUriSameNameDoesNotCollide() { + val d1 = java.nio.file.Files.createTempDirectory("amaze1").toFile() + val d2 = java.nio.file.Files.createTempDirectory("amaze2").toFile() + val f1 = File(d1, "same.txt").apply { writeText("one") } + val f2 = File(d2, "same.txt").apply { writeText("two") } + try { + val u1 = FileUtils.getShareableUri(context, f1)!! + val u2 = FileUtils.getShareableUri(context, f2)!! + assertTrue(u1 != u2) + assertEquals("one", context.contentResolver.openInputStream(u1)!!.use { it.readBytes().toString(Charsets.UTF_8) }) + assertEquals("two", context.contentResolver.openInputStream(u2)!!.use { it.readBytes().toString(Charsets.UTF_8) }) + } finally { + listOf(f1, f2, d1, d2).forEach { it.delete() } + } + } + + @Test + fun testGetShareableUriAsyncReportsRootCopyFailure() { + RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean( + com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE, + true, + ).commit() + val observer = + FileUtils.getShareableUriAsync(context, File("/nonexistent/secret.txt")).test() + observer.assertError(FileUtils.ShareCopyException::class.java) + // no half-written copy is left behind + val leftovers = File(context.cacheDir, FileUtils.OPEN_WITH_CACHE_DIR).listFiles() + assertTrue(leftovers == null || leftovers.all { it.listFiles().isNullOrEmpty() }) + } } diff --git a/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java b/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java index d25e9e8a49..3caa7aef81 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java +++ b/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java @@ -114,7 +114,7 @@ public void testGetUriForBaseFile() { assertEquals( "content://" + ApplicationProvider.getApplicationContext().getPackageName() - + "/storage_root/storage/emulated/0/test.txt", + + "/storage_root/emulated/0/test.txt", uri.toString()); } } From 7870682c0a74f3581f34a4a2fad585f718ef9863 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 24 Oct 2026 17:48:30 +0800 Subject: [PATCH 2/2] Add limited external FTP start/stop control via dangerous permission Make CONTROL_FTP_SERVER signature-only and add CONTROL_FTP_SERVER_SIMPLE (dangerous) guarding a new FtpExternalControlReceiver that only starts or stops the server with saved settings and never forwards intent extras. --- app/src/main/AndroidManifest.xml | 15 ++ .../ftp/FtpExternalControlReceiver.kt | 65 +++++++ .../asynchronous/services/ftp/FtpReceiver.kt | 31 ++- .../asynchronous/services/ftp/FtpService.kt | 87 +++++---- app/src/main/res/values/strings.xml | 2 + .../ftp/FtpExternalControlReceiverTest.kt | 180 ++++++++++++++++++ 6 files changed, 328 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpExternalControlReceiver.kt create mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpExternalControlReceiverTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 503c5fce63..32fa06f64b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + @@ -313,6 +318,16 @@ + + + + + + + , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Receiver for third-party apps (e.g. Tasker) holding the dangerous + * CONTROL_FTP_SERVER_SIMPLE permission. Only starts/stops the server with the user's saved + * settings; extras of the incoming intent are deliberately never forwarded. + */ +class FtpExternalControlReceiver : BroadcastReceiver() { + companion object { + private val logger: Logger = LoggerFactory.getLogger(FtpExternalControlReceiver::class.java) + } + + override fun onReceive( + context: Context, + intent: Intent, + ) { + val service = Intent(context, FtpService::class.java) + runCatching { + when (intent.action) { + FtpService.ACTION_EXTERNAL_START_FTP -> { + if (!FtpService.isRunning()) { + ContextCompat.startForegroundService(context, service) + } + } + + FtpService.ACTION_EXTERNAL_STOP_FTP -> { + context.stopService(service) + } + + else -> { + Unit + } + } + }.onFailure { + logger.error("Failed to start/stop on intent ${it.message}") + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt index 9d71e405a9..82dadd1282 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt @@ -23,34 +23,45 @@ package com.amaze.filemanager.asynchronous.services.ftp import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.util.Log import androidx.core.content.ContextCompat import com.amaze.filemanager.BuildConfig.DEBUG import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.isRunning +import org.slf4j.Logger +import org.slf4j.LoggerFactory /** Created by yashwanthreddyg on 09-06-2016. */ class FtpReceiver : BroadcastReceiver() { - private val TAG = FtpReceiver::class.java.simpleName + companion object { + private val logger: Logger = LoggerFactory.getLogger(FtpReceiver::class.java) + } override fun onReceive( context: Context, intent: Intent, ) { if (DEBUG) { - Log.v(TAG, "Received: ${intent.action}") + logger.debug("Received: ${intent.action}") } val service = Intent(context, FtpService::class.java) service.putExtras(intent) runCatching { - if (intent.action == FtpService.ACTION_START_FTPSERVER && !isRunning()) { - ContextCompat.startForegroundService(context, service) - } else if (intent.action == FtpService.ACTION_STOP_FTPSERVER) { - context.stopService(service) - } else { - Unit + when (intent.action) { + FtpService.ACTION_START_FTPSERVER -> { + if (!isRunning()) { + ContextCompat.startForegroundService(context, service) + } + } + + FtpService.ACTION_STOP_FTPSERVER -> { + context.stopService(service) + } + + else -> { + Unit + } } }.onFailure { - Log.e(TAG, "Failed to start/stop on intent ${it.message}") + logger.error("Failed to start/stop on intent ${it.message}") } } } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt index 2d8d785432..bcfe4aa7dc 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt @@ -78,7 +78,9 @@ import kotlin.concurrent.thread * * Edited by zent-co on 30-07-2019 Edited by bowiechen on 2019-10-19. */ -class FtpService : Service(), Runnable { +class FtpService : + Service(), + Runnable { private val binder: IBinder = ObtainableServiceBinder(this) // Service will broadcast via event bus when server start/stop @@ -137,9 +139,7 @@ class FtpService : Service(), Runnable { wakeLock.setReferenceCounted(false) } - override fun onBind(intent: Intent): IBinder { - return binder - } + override fun onBind(intent: Intent): IBinder = binder @Suppress("LongMethod") override fun run() { @@ -243,7 +243,8 @@ class FtpService : Service(), Runnable { server = createServer().apply { start() - EventBus.getDefault() + EventBus + .getDefault() .post( if (isStartedByTile) { FtpReceiverActions.STARTED_FROM_TILE @@ -315,37 +316,40 @@ class FtpService : Service(), Runnable { "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_START_FTPSERVER" const val ACTION_STOP_FTPSERVER = "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_STOP_FTPSERVER" + const val ACTION_EXTERNAL_START_FTP = "com.amaze.filemanager.action.EXTERNAL_START_FTP" + const val ACTION_EXTERNAL_STOP_FTP = "com.amaze.filemanager.action.EXTERNAL_STOP_FTP" const val TAG_STARTED_BY_TILE = "started_by_tile" // attribute of action_started, used by notification private var _enabledCipherSuites: Array = - LinkedList().apply { - if (SDK_INT >= Q) { - add("TLS_AES_128_GCM_SHA256") - add("TLS_AES_256_GCM_SHA384") - add("TLS_CHACHA20_POLY1305_SHA256") - } - if (SDK_INT >= N) { - add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") - add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") - } - if (SDK_INT >= LOLLIPOP) { - add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") - add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") - add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") - add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") - add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") - add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") - add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") - add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") - add("TLS_RSA_WITH_AES_128_GCM_SHA256") - add("TLS_RSA_WITH_AES_256_GCM_SHA384") - } - if (SDK_INT < LOLLIPOP) { - add("TLS_RSA_WITH_AES_128_CBC_SHA") - add("TLS_RSA_WITH_AES_256_CBC_SHA") - } - }.toTypedArray() + LinkedList() + .apply { + if (SDK_INT >= Q) { + add("TLS_AES_128_GCM_SHA256") + add("TLS_AES_256_GCM_SHA384") + add("TLS_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= N) { + add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") + add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= LOLLIPOP) { + add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") + add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + add("TLS_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_RSA_WITH_AES_256_GCM_SHA384") + } + if (SDK_INT < LOLLIPOP) { + add("TLS_RSA_WITH_AES_128_CBC_SHA") + add("TLS_RSA_WITH_AES_256_CBC_SHA") + } + }.toTypedArray() /** * Return a list of available ciphers for ftpserver. @@ -369,18 +373,19 @@ class FtpService : Service(), Runnable { * based URI if it's running on Android 7.0 or above. */ @JvmStatic - fun defaultPath(context: Context): String { - return if (PreferenceManager.getDefaultSharedPreferences(context) + fun defaultPath(context: Context): String = + if (PreferenceManager + .getDefaultSharedPreferences(context) .getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) && SDK_INT > M ) { - DocumentsContract.buildTreeDocumentUri( - "com.android.externalstorage.documents", - "primary:", - ).toString() + DocumentsContract + .buildTreeDocumentUri( + "com.android.externalstorage.documents", + "primary:", + ).toString() } else { Environment.getExternalStorageDirectory().absolutePath } - } /** * Indicator whether FTP service is running @@ -391,8 +396,6 @@ class FtpService : Service(), Runnable { return !server.isStopped } - private fun getPort(preferences: SharedPreferences): Int { - return preferences.getInt(PORT_PREFERENCE_KEY, DEFAULT_PORT) - } + private fun getPort(preferences: SharedPreferences): Int = preferences.getInt(PORT_PREFERENCE_KEY, DEFAULT_PORT) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 056dd7afd1..de0b6b7379 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -878,5 +878,7 @@ You only need to do this once, until the next time you select a new location for Cleanup interval Trigger auto-cleanup interval (hours) File Deletion + Start and stop FTP server + Allows the app to start or stop Amaze\'s FTP server using your saved FTP settings. It cannot change the FTP settings. diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpExternalControlReceiverTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpExternalControlReceiverTest.kt new file mode 100644 index 0000000000..87572ab334 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpExternalControlReceiverTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2014-2026 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.PermissionInfo +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.shadows.ShadowMultiDex +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.spyk +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowMultiDex::class], sdk = [Build.VERSION_CODES.P]) +@Suppress("StringLiteralDuplication") +class FtpExternalControlReceiverTest { + private lateinit var receiver: FtpExternalControlReceiver + + /** + * Pre-test setup. + */ + @Before + fun setUp() { + mockkObject(FtpService) + receiver = FtpExternalControlReceiver() + } + + /** + * Post test teardown. + */ + @After + fun tearDown() { + unmockkObject(FtpService) + } + + /** + * Start action starts the foreground service when not running. + */ + @Test + fun testStartActionStartsService() { + every { FtpService.isRunning() } returns false + val spy = spyk(AppConfig.getInstance()) + every { spy.startForegroundService(any()) } returns null + receiver.onReceive(spy, Intent(FtpService.ACTION_EXTERNAL_START_FTP)) + verify(exactly = 1) { spy.startForegroundService(any()) } + } + + /** + * Start action is ignored when the server is already running. + */ + @Test + fun testStartActionIgnoredWhenAlreadyRunning() { + every { FtpService.isRunning() } returns true + val spy = spyk(AppConfig.getInstance()) + receiver.onReceive(spy, Intent(FtpService.ACTION_EXTERNAL_START_FTP)) + verify(exactly = 0) { spy.startForegroundService(any()) } + verify(exactly = 0) { spy.startService(any()) } + } + + /** + * Stop action stops [FtpService]. + */ + @Test + fun testStopActionStopsService() { + val spy = spyk(AppConfig.getInstance()) + val captured = slot() + every { spy.stopService(capture(captured)) } returns true + receiver.onReceive(spy, Intent(FtpService.ACTION_EXTERNAL_STOP_FTP)) + verify(exactly = 1) { spy.stopService(any()) } + assertEquals(FtpService::class.java.name, captured.captured.component?.className) + } + + /** + * Null and unknown actions do nothing. + */ + @Test + fun testNullOrUnknownActionIgnored() { + every { FtpService.isRunning() } returns false + val spy = spyk(AppConfig.getInstance()) + receiver.onReceive(spy, Intent()) + receiver.onReceive(spy, Intent("foo")) + verify(exactly = 0) { spy.startForegroundService(any()) } + verify(exactly = 0) { spy.startService(any()) } + verify(exactly = 0) { spy.stopService(any()) } + } + + /** + * Extras sent by external callers must never reach [FtpService]. + */ + @Test + fun testExtrasAreNotForwarded() { + every { FtpService.isRunning() } returns false + val spy = spyk(AppConfig.getInstance()) + val captured = slot() + every { spy.startForegroundService(capture(captured)) } returns null + val intent = + Intent(FtpService.ACTION_EXTERNAL_START_FTP) + .putExtra(FtpService.TAG_STARTED_BY_TILE, true) + .putExtra("ftp_path", "/") + .putExtra("ftp_password", "x") + receiver.onReceive(spy, intent) + assertNull(captured.captured.extras) + } + + /** + * Legacy actions are not handled by this receiver. + */ + @Test + fun testOldActionsIgnored() { + every { FtpService.isRunning() } returns false + val spy = spyk(AppConfig.getInstance()) + receiver.onReceive(spy, Intent(FtpService.ACTION_START_FTPSERVER)) + receiver.onReceive(spy, Intent(FtpService.ACTION_STOP_FTPSERVER)) + verify(exactly = 0) { spy.startForegroundService(any()) } + verify(exactly = 0) { spy.stopService(any()) } + } + + /** + * Verify permission declarations and receiver protection in the manifest. + */ + @Test + fun testManifestDeclarations() { + val ctx = AppConfig.getInstance() + val pm = ctx.packageManager + val signature = pm.getPermissionInfo("${ctx.packageName}.permission.CONTROL_FTP_SERVER", 0) + val simple = + pm.getPermissionInfo("${ctx.packageName}.permission.CONTROL_FTP_SERVER_SIMPLE", 0) + assertEquals( + PermissionInfo.PROTECTION_SIGNATURE, + signature.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE, + ) + assertEquals( + PermissionInfo.PROTECTION_DANGEROUS, + simple.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE, + ) + + val receivers = + pm.getPackageInfo(ctx.packageName, PackageManager.GET_RECEIVERS).receivers.orEmpty() + val external = receivers.firstOrNull { it.name == FtpExternalControlReceiver::class.java.name } + val legacy = receivers.firstOrNull { it.name == FtpReceiver::class.java.name } + assertNotNull(external) + assertNotNull(legacy) + assertTrue(external!!.exported) + assertEquals("${ctx.packageName}.permission.CONTROL_FTP_SERVER_SIMPLE", external.permission) + assertEquals("${ctx.packageName}.permission.CONTROL_FTP_SERVER", legacy!!.permission) + } +}