diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/activities/MainActivityTeleportScrollInstrumentedTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/activities/MainActivityTeleportScrollInstrumentedTest.kt new file mode 100644 index 0000000000..981c1ddccc --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/activities/MainActivityTeleportScrollInstrumentedTest.kt @@ -0,0 +1,110 @@ +/* + * 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.ui.activities + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.LargeTest +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.File + +/** + * Instrumented (emulator/device) test verifying that [MainActivity.teleportToFile] + * actually scrolls the target file into view within the real file-list RecyclerView, + * on top of the headless assertions in [MainActivityTeleportTest]. + * + * Uses plain Espresso view matching (checking the target file's name is displayed on + * screen) rather than accessing MainFragment's `listView`/`adapter` fields directly, + * since those are private -- this keeps the test decoupled from internal implementation + * details, following the same style as the existing TextEditorActivityEspressoTest. + */ +@LargeTest +class MainActivityTeleportScrollInstrumentedTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + private lateinit var testDir: File + private lateinit var targetFile: File + + /** + * Creates a temp directory (inside the app's own external files dir, which needs no + * runtime permission) with enough files that the target file starts off-screen and a + * real scroll is required to bring it into view. + */ + @Before + fun setUp() { + // Use the shared storage root (NOT getExternalFilesDir / Android/data), since Amaze + // treats any path under Android/data specially (Scoped Storage) and prompts for SAF + // access before browsing there, even for its own app-specific folder. The shared + // root instead relies on MANAGE_EXTERNAL_STORAGE, which must be pre-granted via: + // adb shell appops set MANAGE_EXTERNAL_STORAGE allow + testDir = File(android.os.Environment.getExternalStorageDirectory(), "AmazeTeleportScrollTest") + val created = testDir.mkdirs() + check(created || testDir.isDirectory) { + "Failed to create test directory at ${testDir.absolutePath} " + + "(mkdirs() returned $created, exists=${testDir.exists()}, isDirectory=${testDir.isDirectory}). " + + "Did you run: adb shell appops set MANAGE_EXTERNAL_STORAGE allow ?" + } + + for (i in 1..40) { + val file = File(testDir, "file_%02d.txt".format(i)) + check(file.createNewFile() || file.exists()) { + "Failed to create ${file.absolutePath} - parent exists: ${testDir.exists()}" + } + } + targetFile = File(testDir, "file_40.txt") + } + + /** + * Removes the temp test directory after the test finishes. + */ + @After + fun tearDown() { + if (::testDir.isInitialized) { + testDir.deleteRecursively() + } + } + + /** + * Verifies the target file's row becomes visible on screen after teleportToFile is called. + */ + @Test + fun testTeleportScrollsTargetFileIntoView() { + activityRule.scenario.onActivity { activity -> + val file = HybridFile(OpenMode.FILE, targetFile.absolutePath) + activity.teleportToFile(file) + } + + // Give the async directory load + scroll a moment to complete before asserting. + Thread.sleep(2000) + + onView(withText(targetFile.name)) + .check(matches(isDisplayed())) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt b/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt index 5c6afc3259..fc6f1647bb 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt @@ -21,12 +21,14 @@ package com.amaze.filemanager.adapters import android.content.Context +import android.graphics.PorterDuff import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil @@ -91,11 +93,14 @@ class SearchRecyclerViewAdapter : holder.filePathTV.text = file.path.substring(0, file.path.lastIndexOf("/")) holder.colorView.setBackgroundColor(getRandomColor(holder.colorView.context)) + holder.teleportIV.setColorFilter(colorPreference.accent, PorterDuff.Mode.SRC_ATOP) if (file.isDirectory) { holder.colorView.setBackgroundColor(colorPreference.primaryFirstTab) + holder.teleportIV.visibility = View.GONE } else { holder.colorView.setBackgroundColor(colorPreference.accent) + holder.teleportIV.visibility = View.VISIBLE } } @@ -103,12 +108,14 @@ class SearchRecyclerViewAdapter : val fileNameTV: AppCompatTextView val filePathTV: AppCompatTextView val colorView: View + val teleportIV: AppCompatImageView init { fileNameTV = view.findViewById(R.id.searchItemFileNameTV) filePathTV = view.findViewById(R.id.searchItemFilePathTV) colorView = view.findViewById(R.id.searchItemSampleColorView) + teleportIV = view.findViewById(R.id.searchItemTeleportIV) view.setOnClickListener { @@ -127,6 +134,14 @@ class SearchRecyclerViewAdapter : (AppConfig.getInstance().mainActivityContext as MainActivity?) ?.appbar?.searchView?.hideSearchView() } + teleportIV.setOnClickListener { + val (file, _) = getItem(adapterPosition) + if (!file.isDirectory) { + val activity = AppConfig.getInstance().mainActivityContext as MainActivity? + activity?.teleportToFile(file) + activity?.appbar?.searchView?.hideSearchView() + } + } } } diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java index 4844b8e247..5715232a86 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java @@ -1074,6 +1074,17 @@ public void goToMain(String path, boolean hideFab) { } } + public void teleportToFile(HybridFile file) { + String parentPath = file.getParent(this); + if (parentPath == null) { + scrollToFileName = null; + goToMain(file.getPath()); + return; + } + scrollToFileName = file.getName(this); + goToMain(parentPath); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater menuInflater = getMenuInflater(); @@ -2456,11 +2467,11 @@ private void initLeftRightAndTopDragListeners(boolean destroy, boolean shouldInv /** * Invoke {@link FtpServerFragment#changeFTPServerPath(String)} to change FTP server share path. * + * @param dialog + * @param folder selected folder * @see FtpServerFragment#changeFTPServerPath(String) * @see FolderChooserDialog * @see com.afollestad.materialdialogs.folderselector.FolderChooserDialog.FolderCallback - * @param dialog - * @param folder selected folder */ @Override public void onFolderSelection(@NonNull FolderChooserDialog dialog, @NonNull File folder) { @@ -2552,8 +2563,8 @@ public void setListItemSelected(boolean value) { /** * Do nothing other than dismissing the folder selection dialog. * - * @see com.afollestad.materialdialogs.folderselector.FolderChooserDialog.FolderCallback * @param dialog + * @see com.afollestad.materialdialogs.folderselector.FolderChooserDialog.FolderCallback */ @Override public void onFolderChooserDismissed(@NonNull FolderChooserDialog dialog) { diff --git a/app/src/main/res/drawable/ic_folder_arrow_right_outline.xml b/app/src/main/res/drawable/ic_folder_arrow_right_outline.xml new file mode 100644 index 0000000000..a372e0ec23 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_arrow_right_outline.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_row_item.xml b/app/src/main/res/layout/search_row_item.xml index 1864ba376e..160f5fced4 100644 --- a/app/src/main/res/layout/search_row_item.xml +++ b/app/src/main/res/layout/search_row_item.xml @@ -20,6 +20,17 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + @@ -46,7 +57,7 @@ android:letterSpacing="0.05" android:textSize="14sp" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@+id/searchItemTeleportIV" app:layout_constraintStart_toEndOf="@id/searchItemSampleColorView" app:layout_constraintTop_toBottomOf="@id/searchItemFileNameTV" app:layout_constraintVertical_bias="0" /> diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt new file mode 100644 index 0000000000..c7b4ac6dd7 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt @@ -0,0 +1,210 @@ +/* + * 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.ui.activities + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.database.TabHandler +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.shadows.ShadowSmbUtil +import org.junit.Assert.assertEquals +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import java.io.File + +/** + * Headless (Robolectric) tests for [MainActivity.teleportToFile], covering local files, + * networked files (SMB/FTP), and the no-parent fallback case. + * + * These tests exercise [MainActivity.teleportToFile] end-to-end (scrollToFileName + navigation), + * on top of [com.amaze.filemanager.filesystem.HybridFileTest] which already covers the + * underlying getParent/getName path parsing for each protocol. + * + * Each test uses ActivityScenario's `.use { }` (Kotlin AutoCloseable extension) rather than a + * manual close() call at the end of the method, so the scenario -- and its underlying database + * connection -- is always closed even if an assertion inside the block fails. Without this, a + * failing assertion in one test can leave a connection open and cause unrelated failures (or + * Windows-specific file-lock crashes during Robolectric's temp directory cleanup) in later tests. + */ +@Config(shadows = [ShadowSmbUtil::class]) +class MainActivityTeleportTest : AbstractMainActivityTestBase() { + /** + * TabHandler is a Bill-Pugh singleton whose `database` field is captured once, + * at class-load time, pointing at whatever ExplorerDatabase / SQLite connection + * existed in the Application at that moment. + * + * Robolectric tears down and rebuilds the Application (and its SQLite connections) + * between test methods, but this JVM-wide singleton survives across tests in the + * same run and keeps holding a stale connection -- causing an + * "Illegal connection pointer" IllegalStateException once a second test launches + * an Activity that touches TabHandler (via TabFragment.refactorDrawerStorages -> + * getAllTabs()). + * + * Since TabHandler is shared/upstream code, we refresh its internal `database` + * reference via reflection before every test instead of modifying it, pointing it + * at the current test's fresh ExplorerDatabase instance. Test-only workaround. + */ + @Before + fun refreshTabHandlerDatabaseReference() { + runCatching { + val tabHandler = TabHandler.getInstance() + val databaseField = TabHandler::class.java.getDeclaredField("database") + databaseField.isAccessible = true + + val unsafeClass = Class.forName("sun.misc.Unsafe") + val unsafeField = unsafeClass.getDeclaredField("theUnsafe") + unsafeField.isAccessible = true + val unsafe: Any = unsafeField.get(null) + + val objectFieldOffsetMethod = + unsafeClass.getMethod("objectFieldOffset", java.lang.reflect.Field::class.java) + val offset = objectFieldOffsetMethod.invoke(unsafe, databaseField) as Long + + val putObjectMethod = + unsafeClass.getMethod( + "putObject", + Any::class.java, + Long::class.javaPrimitiveType, + Any::class.java, + ) + putObjectMethod.invoke( + unsafe, + tabHandler, + offset, + AppConfig.getInstance().explorerDatabase, + ) + }.onFailure { + println("WARN: failed to refresh TabHandler database reference via reflection: ${it.message}") + } + } + + /** + * Verifies teleporting to a normal local file sets scrollToFileName and navigates + * to the file's parent directory. + */ + @Test + fun testTeleportToLocalFile() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + ShadowLooper.idleMainLooper() + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { activity: MainActivity -> + val file = HybridFile(OpenMode.FILE, "/storage/emulated/0/Documents/report.pdf") + + activity.teleportToFile(file) + ShadowLooper.idleMainLooper() + + assertEquals("report.pdf", activity.scrollToFileName) + // Local (java.io.File-based) getParent() uses the host OS separator, which is + // "\" when this test runs on a Windows dev machine but always "/" on a real + // Android device. Normalize before comparing so the test is host-independent. + assertEquals( + "/storage/emulated/0/Documents", + activity.currentMainFragment?.currentPath?.replace(File.separatorChar, '/'), + ) + } + scenario.moveToState(Lifecycle.State.DESTROYED) + } + } + + /** + * Verifies teleporting to an SMB file sets scrollToFileName and navigates + * to the file's parent directory, without requiring a live SMB server + * (getParent/getName resolve purely from the path string). + */ + @Test + fun testTeleportToSmbFile() { + io.reactivex.plugins.RxJavaPlugins.setErrorHandler { } + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + ShadowLooper.idleMainLooper() + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { activity: MainActivity -> + val file = + HybridFile(OpenMode.SMB, "smb://user:password@1.2.3.4/share/folder/file.pdf") + + activity.teleportToFile(file) + ShadowLooper.idleMainLooper() + + assertEquals("file.pdf", activity.scrollToFileName) + assertEquals( + "smb://user:password@1.2.3.4/share/folder", + activity.currentMainFragment?.currentPath, + ) + } + scenario.moveToState(Lifecycle.State.DESTROYED) + } + } + + /** + * Verifies teleporting to an FTP file sets scrollToFileName and navigates + * to the file's parent directory, without requiring a live FTP server. + */ + @Test + fun testTeleportToFtpFile() { + Assume.assumeFalse(System.getProperty("os.name").lowercase().contains("win")) + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.moveToState(Lifecycle.State.STARTED) + + scenario.onActivity { activity: MainActivity -> + val file = + HybridFile( + OpenMode.FTP, + "ftp://user:password@127.0.0.1:22222/uploads/document.docx", + ) + activity.teleportToFile(file) + + assertEquals("document.docx", activity.scrollToFileName) + assertEquals( + "ftp://user:password@127.0.0.1:22222/uploads", + activity.currentMainFragment?.currentPath, + ) + } + + scenario.moveToState(Lifecycle.State.DESTROYED) + } + } + + /** + * Verifies teleporting to a file with no resolvable parent (e.g. a root-level path) + * falls back gracefully to navigating to the file's own path, without crashing. + */ + @Test + fun testTeleportToFileWithNoParentFallsBackGracefully() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + ShadowLooper.idleMainLooper() + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { activity: MainActivity -> + val file = HybridFile(OpenMode.FILE, "/") + + // Should not throw, regardless of whether getParent resolves or not. + activity.teleportToFile(file) + ShadowLooper.idleMainLooper() + assertEquals(null, activity.scrollToFileName) + } + scenario.moveToState(Lifecycle.State.DESTROYED) + } + } +} diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt b/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt index c4a1d2d212..6c279233f0 100644 --- a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt +++ b/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt @@ -186,6 +186,17 @@ class ShadowSmbUtil { `when`(it.name).thenReturn(path.substring(path.lastIndexOf('/') + 1)) `when`(it.path).thenReturn(path) `when`(it.context).thenReturn(SingletonContext.getInstance()) + val parentPath = + if (path.lastIndexOf('/') != -1) { + path.substring(0, path.lastIndexOf('/')) + } else { + null + } + `when`(it.parent).thenReturn(parentPath) + // By default, Mockito returns null for methods returning arrays. + // We mock listFiles() to return an empty array to prevent a NullPointerException + // in MainFragment.addToSmb when LoadFilesListTask executes during headless tests. + `when`(it.listFiles()).thenReturn(emptyArray()) } } }