Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
package com.amaze.filemanager.filesystem;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import org.junit.Before;
import org.junit.Test;

import com.amaze.filemanager.fileoperations.filesystem.OpenMode;

import android.net.Uri;
import android.os.Parcel;

/** Created by Rustam Khadipash on 29/3/2018. */
Expand Down Expand Up @@ -197,6 +199,51 @@ public void setPermission() {
assertEquals("rwx", file.getPermission());
}

/**
* Purpose: Verify that setFullUri(null) does NOT throw NullPointerException. This is a regression
* test for the crash that occurred when OTGUtil.getDocumentFiles used opportunistic direct
* filesystem access (OtgFileAccessFacade) and set fullUri = null.
*
* <p>Expected: No exception thrown; getFullUri returns null.
*/
@Test
public void setFullUriNullDoesNotThrowNPE() {
// Must not throw NullPointerException
file.setFullUri(null);
// getFullUri only returns non-null for DOCUMENT_FILE mode; file is OpenMode.FILE
assertNull(file.getFullUri());
}

/**
* Purpose: Verify setFullUri accepts a valid content:// URI and stores it for DOCUMENT_FILE mode.
* Expected: getFullUri returns the URI when mode is DOCUMENT_FILE.
*/
@Test
public void setFullUriContentSchemeStoredForDocumentFileMode() {
HybridFileParcelable docFile =
new HybridFileParcelable(
"content://com.android.externalstorage.documents/tree/1234-ABCD%3A",
"rw", 0L, 0L, true);
docFile.setMode(OpenMode.DOCUMENT_FILE);
Uri contentUri = Uri.parse("content://com.android.externalstorage.documents/tree/1234-ABCD%3A");
docFile.setFullUri(contentUri);
assertEquals(contentUri, docFile.getFullUri());
}

/**
* Purpose: Verify setFullUri silently ignores non-content URIs (e.g. file://). Expected:
* getFullUri returns null for non-content scheme URIs.
*/
@Test
public void setFullUriNonContentSchemeIgnored() {
HybridFileParcelable docFile =
new HybridFileParcelable("file:///storage/emulated/0/test.txt", "rw", 0L, 0L, false);
docFile.setMode(OpenMode.DOCUMENT_FILE);
Uri fileUri = Uri.parse("file:///storage/emulated/0/test.txt");
docFile.setFullUri(fileUri); // non-content:// URI, should be silently ignored
assertNull(docFile.getFullUri());
}

/**
* Purpose: Write an object into a parcel, send it through an intent and read the object from the
* parcel Input: writeToParcel parcel object Expected: The parcel can be sent and the object can
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright (C) 2014-2026 Arpit Khurana <arpitkh96@gmail.com>, Vishal Nehra <vishalmeham2@gmail.com>,
* Emmanuel Messulam<emmanuelbendavid@gmail.com>, Raymond Lai <airwave209gt at gmail.com> 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 <http://www.gnu.org/licenses/>.
*/

package com.amaze.filemanager.ui.dialogs;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.pressBack;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withText;

import java.io.IOException;

import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import com.amaze.filemanager.R;
import com.amaze.filemanager.ui.activities.MainActivity;

import android.os.Build;

import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.UiDevice;

/** Integration tests for OTG eject dialog */
@RunWith(AndroidJUnit4.class)
public class OTGEjectDialogTest {

@Rule
public ActivityScenarioRule<MainActivity> activityRule =
new ActivityScenarioRule<>(MainActivity.class);

/**
* On Android 11+, MainActivity shows a "grant all files access" dialog on top of any other dialog
* shown afterwards (it re-checks and re-shows it on every onResume), which hides the OTG eject
* dialog from the view hierarchy and makes Espresso assertions fail with NoMatchingViewException.
*
* <p>Rather than clicking through the dialog + Settings toggle with UiAutomator (flaky: races
* with the dialog's asynchronous appearance, and can even toggle permission back OFF if clicked
* when already granted), grant MANAGE_EXTERNAL_STORAGE directly via an `appops` shell command.
* This must run in @BeforeClass - before activityRule's @Rule launches MainActivity for the first
* time - so the dialog never has a chance to appear at all.
*/
@BeforeClass
public static void grantManageStoragePermission() throws IOException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return;
}

String packageName =
InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName();
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
device.executeShellCommand("appops set " + packageName + " MANAGE_EXTERNAL_STORAGE allow");
}

/**
* Dismiss the OTG eject dialog shown by the test via the back button, instead of destroying (and
* thus relaunching) the whole MainActivity instance between every test. Since MainActivity is
* declared launchMode="singleInstance", destroying/relaunching it on every test causes
* window-focus churn on the emulator and intermittent
* RootViewPicker$RootViewWithoutFocusException failures. Keeping a single stable instance alive
* for the whole test class avoids that.
*/
@After
public void dismissDialog() {
try {
pressBack();
} catch (Exception ignored) {
// No dialog/view to dismiss - nothing to do.
}
}

/**
* Shows the OTG eject dialog and waits for the window manager/animations to settle before handing
* control back to the caller for Espresso assertions. Without this, Espresso can intermittently
* fail with RootViewPicker$RootViewWithoutFocusException on slower devices/emulators while the
* dialog window is still transitioning into focus.
*/
private void showOtgEjectDialogAndWaitForIdle(
ActivityScenario<MainActivity> scenario,
String deviceKey,
String devicePath,
boolean isRootAvailable) {
scenario.onActivity(
mainActivity ->
GeneralDialogCreation.showOtgEjectDialog(
mainActivity, deviceKey, devicePath, isRootAvailable));
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).waitForIdle();
}

@Test
public void testEjectDialogDisplaysTitle() {
// Test that the eject dialog shows the correct title
String deviceKey = "1234:5678";
String devicePath = "otg:/1234:5678/";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(scenario, deviceKey, devicePath, false);

// Verify dialog title is displayed. Espresso view assertions must run on the
// instrumentation thread, not inside onActivity() which runs on the main thread.
onView(withText(R.string.otg_eject_title)).check(matches(isDisplayed()));
}

@Test
public void testEjectDialogDisplaysMessage() {
// Test that warning message is displayed
String deviceKey = "1234:5678";
String devicePath = "otg:/1234:5678/";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(scenario, deviceKey, devicePath, false);

// Verify warning message is displayed
onView(withText(R.string.otg_eject_message)).check(matches(isDisplayed()));
}

@Test
public void testEjectDialogPrimaryButton() {
// Test that positive action button is available
String deviceKey = "1234:5678";
String devicePath = "otg:/1234:5678/";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(scenario, deviceKey, devicePath, false);

// Verify action button is displayed
onView(withText(R.string.otg_eject_action)).check(matches(isDisplayed()));
}

@Test
public void testEjectDialogWithRootUnmountOption() {
// Test that root unmount button appears when root is available
String deviceKey = "1234:5678";
String devicePath = "otg:/1234:5678/";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(
scenario, deviceKey, devicePath, true // isRootAvailable = true
);

// Verify root unmount option is displayed
onView(withText(R.string.otg_eject_root_option)).check(matches(isDisplayed()));
}

@Test
public void testEjectDialogWithoutRootUnmountOption() {
// Test that root unmount button doesn't appear when root unavailable
String deviceKey = "1234:5678";
String devicePath = "otg:/1234:5678/";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(
scenario, deviceKey, devicePath, false // isRootAvailable = false
);

// Root unmount option should not be visible
onView(withText(R.string.otg_eject_root_option)).check(doesNotExist());
}

@Test
public void testEjectDialogWithDirectAccessPath() {
// Test dialog with direct /mnt/media_rw path
String deviceKey = null; // Direct paths don't need device key
String devicePath = "/mnt/media_rw/USB-DISK";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(scenario, deviceKey, devicePath, true);

// Dialog should still display correctly
onView(withText(R.string.otg_eject_title)).check(matches(isDisplayed()));
}

@Test
public void testEjectDialogWithStoragePath() {
// Test dialog with /storage path
String deviceKey = null;
String devicePath = "/storage/XXXX-YYYY";

ActivityScenario<MainActivity> scenario = activityRule.getScenario();
showOtgEjectDialogAndWaitForIdle(scenario, deviceKey, devicePath, true);

// Dialog should still display correctly
onView(withText(R.string.otg_eject_message)).check(matches(isDisplayed()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ private boolean doDeleteFile(@NonNull HybridFileParcelable file) throws Exceptio
case OTG:
DocumentFile documentFile =
OTGUtil.getDocumentFile(file.getPath(), applicationContext, false);
if (documentFile == null) {
LOG.warn("OTG DocumentFile is null for delete - permission may not be granted");
return false;
}
return documentFile.delete();
case DOCUMENT_FILE:
documentFile =
Expand All @@ -175,6 +179,10 @@ private boolean doDeleteFile(@NonNull HybridFileParcelable file) throws Exceptio
applicationContext,
OpenMode.DOCUMENT_FILE,
false);
if (documentFile == null) {
LOG.warn("DocumentFile is null for delete");
return false;
}
return documentFile.delete();
case DROPBOX:
case BOX:
Expand Down
23 changes: 20 additions & 3 deletions app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,8 @@
s = getDocumentFile(false).length();
break;
case OTG:
s = OTGUtil.getDocumentFile(path, context, false).length();
DocumentFile otgDocFile = OTGUtil.getDocumentFile(path, context, false);
s = otgDocFile != null ? otgDocFile.length() : 0L;
break;
case DROPBOX:
case BOX:
Expand Down Expand Up @@ -476,7 +477,13 @@
if (!Utils.isNullOrEmpty(name)) {
return name;
}
return OTGUtil.getDocumentFile(path, context, false).getName();
DocumentFile otgNameFile = OTGUtil.getDocumentFile(path, context, false);
if (otgNameFile != null) {
return otgNameFile.getName();
}
// Fallback: extract from path
String otgPath = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
return otgPath.substring(otgPath.lastIndexOf('/') + 1);
case DOCUMENT_FILE:
if (!Utils.isNullOrEmpty(name)) {
return name;
Expand Down Expand Up @@ -609,7 +616,7 @@
*
* @deprecated use {@link #isDirectory(Context)} to handle content resolvers
*/
public boolean isDirectory() {

Check warning on line 619 in app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java

View workflow job for this annotation

GitHub Actions / Build debug and run Jacoco tests

[dep-ann] deprecated item is not annotated with @deprecated

Check warning on line 619 in app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java

View workflow job for this annotation

GitHub Actions / Build debug and run Jacoco tests

[dep-ann] deprecated item is not annotated with @deprecated
boolean isDirectory;
switch (mode) {
case SFTP:
Expand Down Expand Up @@ -703,7 +710,7 @@
/**
* @deprecated use {@link #folderSize(Context)}
*/
public long folderSize() {

Check warning on line 713 in app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java

View workflow job for this annotation

GitHub Actions / Build debug and run Jacoco tests

[dep-ann] deprecated item is not annotated with @deprecated

Check warning on line 713 in app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java

View workflow job for this annotation

GitHub Actions / Build debug and run Jacoco tests

[dep-ann] deprecated item is not annotated with @deprecated
long size = 0L;

switch (mode) {
Expand Down Expand Up @@ -946,7 +953,7 @@
case OTG:
// TODO: Find total storage space of OTG when {@link DocumentFile} API adds support
DocumentFile documentFile = OTGUtil.getDocumentFile(path, context, false);
size = documentFile.length();
size = documentFile != null ? documentFile.length() : 0L;
break;
case DOCUMENT_FILE:
size = getDocumentFile(false).length();
Expand Down Expand Up @@ -1062,7 +1069,7 @@
*
* @deprecated use forEachChildrenFile()
*/
public ArrayList<HybridFileParcelable> listFiles(Context context, boolean isRoot) {

Check warning on line 1072 in app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java

View workflow job for this annotation

GitHub Actions / Build debug and run Jacoco tests

[dep-ann] deprecated item is not annotated with @deprecated

Check warning on line 1072 in app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java

View workflow job for this annotation

GitHub Actions / Build debug and run Jacoco tests

[dep-ann] deprecated item is not annotated with @deprecated
ArrayList<HybridFileParcelable> arrayList = new ArrayList<>();
forEachChildrenFile(context, isRoot, arrayList::add);
return arrayList;
Expand Down Expand Up @@ -1180,6 +1187,11 @@
case OTG:
contentResolver = context.getContentResolver();
documentSourceFile = OTGUtil.getDocumentFile(path, context, false);
if (documentSourceFile == null) {
LOG.warn("OTG DocumentFile is null - permission may not be granted for path: {}", path);
inputStream = null;
break;
}
try {
inputStream = contentResolver.openInputStream(documentSourceFile.getUri());
} catch (FileNotFoundException e) {
Expand Down Expand Up @@ -1279,6 +1291,11 @@
case OTG:
contentResolver = context.getContentResolver();
documentSourceFile = OTGUtil.getDocumentFile(path, context, true);
if (documentSourceFile == null) {
LOG.warn("OTG DocumentFile is null - permission may not be granted for path: {}", path);
outputStream = null;
break;
}
try {
outputStream = contentResolver.openOutputStream(documentSourceFile.getUri());
} catch (FileNotFoundException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ public Uri getFullUri() {
}

public void setFullUri(Uri fullUri) {
if (fullUri == null) {
this.fullUri = null;
return;
}
if (!ContentResolver.SCHEME_CONTENT.equals(fullUri.getScheme())) {
// TODO: throw IllegalArgumentException is not a good idea here?
// FIXME: OpenMode is mutable (which is a bad idea) hence check for OpenMode.DOCUMENT_FILE
Expand Down
Loading