From 9713c312a0462b116d64a12d0a9112f860d6fb59 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Sat, 15 Aug 2026 21:21:49 +0300 Subject: [PATCH 1/7] Add teleport to file feature from search results --- .../adapters/SearchRecyclerViewAdapter.kt | 16 ++++++++++++++++ .../filemanager/ui/activities/MainActivity.java | 15 ++++++++++++--- .../main/res/drawable/ic_location_on_24dp.png | Bin 0 -> 307 bytes app/src/main/res/layout/search_row_item.xml | 15 +++++++++++++-- 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/drawable/ic_location_on_24dp.png 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..938d045d65 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,15 @@ class SearchRecyclerViewAdapter : (AppConfig.getInstance().mainActivityContext as MainActivity?) ?.appbar?.searchView?.hideSearchView() } + teleportIV.setOnClickListener { + val (file, _) = getItem(adapterPosition) + if (!file.isDirectory) { + (AppConfig.getInstance().mainActivityContext as MainActivity?) + ?.teleportToFile(file.path) + (AppConfig.getInstance().mainActivityContext as MainActivity?) + ?.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 9b3d90904c..54047e7926 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 @@ -1069,6 +1069,15 @@ public void goToMain(String path, boolean hideFab) { } } + public void teleportToFile(String filePath) { + int lastSlash = filePath.lastIndexOf("/"); + String parentPath = filePath.substring(0, lastSlash); + String fileName = filePath.substring(lastSlash + 1); + + scrollToFileName = fileName; + goToMain(parentPath); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater menuInflater = getMenuInflater(); @@ -2451,11 +2460,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) { @@ -2547,8 +2556,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_location_on_24dp.png b/app/src/main/res/drawable/ic_location_on_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..11084e80658ee62e65046b34b8caae0a90f2c38c GIT binary patch literal 307 zcmV-30nGl1P)CPW z&uh)GZYTn#X_}pLw-$31fRYG8u|>rlw3x~@zX70YxZP@_{Bu8!cX0I+9Wn3-0O*tj z;^W>C7x{AmKu&Bm-W~7;00960W!y-h00006Nkl + + @@ -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" /> From f19ba5426471b86fedb8eeb28c0ca6cc52560d34 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Sun, 16 Aug 2026 01:18:48 +0300 Subject: [PATCH 2/7] Fix teleport path splitting to avoid android.util.Pair mocking issue in unit tests --- .../ui/activities/MainActivity.java | 33 +++++++++- .../ui/activities/MainActivityTeleportTest.kt | 65 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt 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 54047e7926..1891c7446f 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 @@ -1070,12 +1070,41 @@ public void goToMain(String path, boolean hideFab) { } public void teleportToFile(String filePath) { + ParentAndFileName split = splitParentAndFileName(filePath); + if (split == null) { + goToMain(filePath); + return; + } + scrollToFileName = split.fileName; + goToMain(split.parentPath); + } + + /** + * Splits a file path into its parent directory path and file name. Returns null if the path has + * no parent (no "/" found). + */ + static ParentAndFileName splitParentAndFileName(String filePath) { int lastSlash = filePath.lastIndexOf("/"); + if (lastSlash <= 0) { + return null; + } String parentPath = filePath.substring(0, lastSlash); String fileName = filePath.substring(lastSlash + 1); + return new ParentAndFileName(parentPath, fileName); + } + + /** + * Simple holder for a file's parent path and name. Not an android.util.Pair to keep it usable in + * plain JUnit tests (android.* classes are stubbed out in non-Robolectric unit tests). + */ + static class ParentAndFileName { + final String parentPath; + final String fileName; - scrollToFileName = fileName; - goToMain(parentPath); + ParentAndFileName(String parentPath, String fileName) { + this.parentPath = parentPath; + this.fileName = fileName; + } } @Override 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..f864a3bcd4 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt @@ -0,0 +1,65 @@ +/* + * 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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Tests for [MainActivity.splitParentAndFileName], used by the search + * "teleport to file" feature to navigate to a file's parent folder. + */ +class MainActivityTeleportTest { + @Test + fun `splits a normal file path into parent and file name`() { + val result = MainActivity.splitParentAndFileName("/storage/emulated/0/Documents/report.pdf") + + assertEquals("/storage/emulated/0/Documents", result?.parentPath) + assertEquals("report.pdf", result?.fileName) + } + + @Test + fun `splits a path with a single directory level`() { + val result = MainActivity.splitParentAndFileName("/storage/file.txt") + + assertEquals("/storage", result?.parentPath) + assertEquals("file.txt", result?.fileName) + } + + @Test + fun `returns null when path has no parent directory`() { + val result = MainActivity.splitParentAndFileName("file.txt") + + assertNull(result) + } + + @Test + fun `handles file names containing spaces and special characters`() { + val result = + MainActivity.splitParentAndFileName( + "/storage/emulated/0/WhatsApp/Media/Yasmin Hosam (1).pdf", + ) + + assertEquals("/storage/emulated/0/WhatsApp/Media", result?.parentPath) + assertEquals("Yasmin Hosam (1).pdf", result?.fileName) + } +} From f9aca24686b6a720e4bd1971d0a9150b95d40c50 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Sun, 16 Aug 2026 01:40:34 +0300 Subject: [PATCH 3/7] Add missing KDoc to test functions --- .../filemanager/ui/activities/MainActivityTeleportTest.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 index f864a3bcd4..4f3ae21a1b 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt @@ -29,6 +29,8 @@ import org.junit.Test * "teleport to file" feature to navigate to a file's parent folder. */ class MainActivityTeleportTest { + + /** Verifies a normal file path is split into its parent directory and file name. */ @Test fun `splits a normal file path into parent and file name`() { val result = MainActivity.splitParentAndFileName("/storage/emulated/0/Documents/report.pdf") @@ -37,6 +39,7 @@ class MainActivityTeleportTest { assertEquals("report.pdf", result?.fileName) } + /** Verifies a path with only one directory level splits correctly. */ @Test fun `splits a path with a single directory level`() { val result = MainActivity.splitParentAndFileName("/storage/file.txt") @@ -45,6 +48,7 @@ class MainActivityTeleportTest { assertEquals("file.txt", result?.fileName) } + /** Verifies null is returned when the path has no parent directory. */ @Test fun `returns null when path has no parent directory`() { val result = MainActivity.splitParentAndFileName("file.txt") @@ -52,6 +56,7 @@ class MainActivityTeleportTest { assertNull(result) } + /** Verifies file names with spaces and special characters split correctly. */ @Test fun `handles file names containing spaces and special characters`() { val result = From 8f00e28074b5c01016070993cb3c473c80838948 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Fri, 28 Aug 2026 17:14:04 +0300 Subject: [PATCH 4/7] fix(search):refactor teleportToFile to use HybridFile and update search result icon * Update `teleportToFile` in `MainActivity` to accept a `HybridFile` object instead of a string path, leveraging existing methods for parent path and file name retrieval. * Remove manual path-splitting logic and the `ParentAndFileName` helper class. * Update `SearchRecyclerViewAdapter` to pass the `HybridFile` object to `teleportToFile`. * Replace the `ic_location_on_24dp` PNG icon with a new `ic_folder_arrow_right_outline` vector drawable in search results. --- .../adapters/SearchRecyclerViewAdapter.kt | 7 ++- .../ui/activities/MainActivity.java | 41 +++--------------- .../ic_folder_arrow_right_outline.xml | 9 ++++ .../main/res/drawable/ic_location_on_24dp.png | Bin 307 -> 0 bytes app/src/main/res/layout/search_row_item.xml | 2 +- 5 files changed, 20 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/drawable/ic_folder_arrow_right_outline.xml delete mode 100644 app/src/main/res/drawable/ic_location_on_24dp.png 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 938d045d65..fc6f1647bb 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt @@ -137,10 +137,9 @@ class SearchRecyclerViewAdapter : teleportIV.setOnClickListener { val (file, _) = getItem(adapterPosition) if (!file.isDirectory) { - (AppConfig.getInstance().mainActivityContext as MainActivity?) - ?.teleportToFile(file.path) - (AppConfig.getInstance().mainActivityContext as MainActivity?) - ?.appbar?.searchView?.hideSearchView() + 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 1891c7446f..cda1b28132 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 @@ -1069,42 +1069,15 @@ public void goToMain(String path, boolean hideFab) { } } - public void teleportToFile(String filePath) { - ParentAndFileName split = splitParentAndFileName(filePath); - if (split == null) { - goToMain(filePath); + public void teleportToFile(HybridFile file) { + String parentPath = file.getParent(this); + if (parentPath == null) { + scrollToFileName = null; + goToMain(file.getPath()); return; } - scrollToFileName = split.fileName; - goToMain(split.parentPath); - } - - /** - * Splits a file path into its parent directory path and file name. Returns null if the path has - * no parent (no "/" found). - */ - static ParentAndFileName splitParentAndFileName(String filePath) { - int lastSlash = filePath.lastIndexOf("/"); - if (lastSlash <= 0) { - return null; - } - String parentPath = filePath.substring(0, lastSlash); - String fileName = filePath.substring(lastSlash + 1); - return new ParentAndFileName(parentPath, fileName); - } - - /** - * Simple holder for a file's parent path and name. Not an android.util.Pair to keep it usable in - * plain JUnit tests (android.* classes are stubbed out in non-Robolectric unit tests). - */ - static class ParentAndFileName { - final String parentPath; - final String fileName; - - ParentAndFileName(String parentPath, String fileName) { - this.parentPath = parentPath; - this.fileName = fileName; - } + scrollToFileName = file.getName(this); + goToMain(parentPath); } @Override 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/drawable/ic_location_on_24dp.png b/app/src/main/res/drawable/ic_location_on_24dp.png deleted file mode 100644 index 11084e80658ee62e65046b34b8caae0a90f2c38c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 307 zcmV-30nGl1P)CPW z&uh)GZYTn#X_}pLw-$31fRYG8u|>rlw3x~@zX70YxZP@_{Bu8!cX0I+9Wn3-0O*tj z;^W>C7x{AmKu&Bm-W~7;00960W!y-h00006Nkl From ab0b27bc84a3b1ab95520e7961f9e163d017d592 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Fri, 28 Aug 2026 17:17:23 +0300 Subject: [PATCH 5/7] test(search): add Robolectric and Espresso tests for teleport-to-file Replaces the basic path-splitting unit tests with end-to-end coverage: - Robolectric tests for local, SMB, and FTP teleport paths, plus the no-parent fallback case. - Espresso instrumented test verifying the target file scrolls into view in the real RecyclerView. - ShadowSmbUtil updated to mock parent paths and prevent an NPE during headless SMB file listing. - Reflection-based workaround refreshing TabHandler's cached database reference before each test to avoid a stale SQLite connection left over from Robolectric rebuilding the Application between tests. --- ...nActivityTeleportScrollInstrumentedTest.kt | 110 ++++++++++ .../ui/activities/MainActivityTeleportTest.kt | 192 +++++++++++++++--- .../filemanager/shadows/ShadowSmbUtil.kt | 11 + 3 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 app/src/androidTest/java/com/amaze/filemanager/ui/activities/MainActivityTeleportScrollInstrumentedTest.kt 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/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt index 4f3ae21a1b..c7b4ac6dd7 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/MainActivityTeleportTest.kt @@ -20,51 +20,191 @@ 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.Assert.assertNull +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 /** - * Tests for [MainActivity.splitParentAndFileName], used by the search - * "teleport to file" feature to navigate to a file's parent folder. + * 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. */ -class MainActivityTeleportTest { +@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 a normal file path is split into its parent directory and file name. */ + /** + * Verifies teleporting to a normal local file sets scrollToFileName and navigates + * to the file's parent directory. + */ @Test - fun `splits a normal file path into parent and file name`() { - val result = MainActivity.splitParentAndFileName("/storage/emulated/0/Documents/report.pdf") + 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") - assertEquals("/storage/emulated/0/Documents", result?.parentPath) - assertEquals("report.pdf", result?.fileName) + 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 a path with only one directory level splits correctly. */ + /** + * 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 `splits a path with a single directory level`() { - val result = MainActivity.splitParentAndFileName("/storage/file.txt") + 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("/storage", result?.parentPath) - assertEquals("file.txt", result?.fileName) + assertEquals("file.pdf", activity.scrollToFileName) + assertEquals( + "smb://user:password@1.2.3.4/share/folder", + activity.currentMainFragment?.currentPath, + ) + } + scenario.moveToState(Lifecycle.State.DESTROYED) + } } - /** Verifies null is returned when the path has no parent directory. */ + /** + * Verifies teleporting to an FTP file sets scrollToFileName and navigates + * to the file's parent directory, without requiring a live FTP server. + */ @Test - fun `returns null when path has no parent directory`() { - val result = MainActivity.splitParentAndFileName("file.txt") + fun testTeleportToFtpFile() { + Assume.assumeFalse(System.getProperty("os.name").lowercase().contains("win")) + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.moveToState(Lifecycle.State.STARTED) - assertNull(result) + 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 file names with spaces and special characters split correctly. */ + /** + * 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 `handles file names containing spaces and special characters`() { - val result = - MainActivity.splitParentAndFileName( - "/storage/emulated/0/WhatsApp/Media/Yasmin Hosam (1).pdf", - ) + 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, "/") - assertEquals("/storage/emulated/0/WhatsApp/Media", result?.parentPath) - assertEquals("Yasmin Hosam (1).pdf", result?.fileName) + // 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()) } } } From b0465ff01ecadfe937d5708aaa96523b5e2ca963 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Tue, 8 Sep 2026 20:23:22 +0300 Subject: [PATCH 6/7] fix(search): clear scrollToFileName after use to prevent stale highlight scrollToFileName was only cleared when teleportToFile had no resolvable parent, so it stayed set indefinitely otherwise. Any later folder reload would re-trigger a highlight for that file, even on unrelated navigation. Now cleared immediately once consumed in MainFragment, making it a one-shot value. Also moved notifyItemChanged() inside the scrollPosition != -1 check to avoid calling it with an invalid index. --- .../amaze/filemanager/ui/activities/MainActivity.java | 4 ++++ .../amaze/filemanager/ui/fragments/MainFragment.java | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) 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 5715232a86..54dadb57d4 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 @@ -2551,6 +2551,10 @@ public String getScrollToFileName() { return this.scrollToFileName; } + public void setScrollToFileName(String scrollToFileName) { + this.scrollToFileName = scrollToFileName; + } + /** * Set list item selected value * 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 1a52f88fdc..c5ea18c2a5 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 @@ -965,17 +965,22 @@ public void reloadListElements(boolean back, boolean grid) { String fileName = requireMainActivity().getScrollToFileName(); - if (fileName != null) + if (fileName != null) { + // Consume the value immediately so it only triggers a highlight once, + // instead of re-highlighting this file on every future reload. + requireMainActivity().setScrollToFileName(null); mainFragmentViewModel .getScrollPosition(fileName) .observe( getViewLifecycleOwner(), scrollPosition -> { - if (scrollPosition != -1) + if (scrollPosition != -1) { listView.scrollToPosition( Math.min(scrollPosition + 4, adapter.getItemCount() - 1)); - adapter.notifyItemChanged(scrollPosition); + adapter.notifyItemChanged(scrollPosition); + } }); + } }); } From cc522c949fc3a50e05f775c2c29a038474742450 Mon Sep 17 00:00:00 2001 From: yasminhosam Date: Tue, 15 Sep 2026 13:07:03 +0300 Subject: [PATCH 7/7] fix(search): keep scrollToFileName until teleport target is found reloadListElements can run more than once per navigation (once with a partial list while loading, once with the full list). Clearing scrollToFileName immediately meant the second, complete pass never got a chance to search for it, so teleporting to a file already in the current folder never scrolled or highlighted. Move the clear to only happen once getScrollPosition actually finds the file, so earlier partial passes don't consume it. Also make getScrollPosition synchronous. It only searches the in-memory adapterListItems, no I/O, so the previous Dispatchers.IO + LiveData(-1) setup added a race for no reason and was the underlying cause of the bug above. --- .../ui/fragments/MainFragment.java | 25 ++++++------------- .../fragments/data/MainFragmentViewModel.kt | 18 +++++-------- 2 files changed, 14 insertions(+), 29 deletions(-) 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 c5ea18c2a5..d7669d05d0 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 @@ -964,23 +964,14 @@ public void reloadListElements(boolean back, boolean grid) { if (!isAdded()) return; String fileName = requireMainActivity().getScrollToFileName(); - - if (fileName != null) { - // Consume the value immediately so it only triggers a highlight once, - // instead of re-highlighting this file on every future reload. - requireMainActivity().setScrollToFileName(null); - mainFragmentViewModel - .getScrollPosition(fileName) - .observe( - getViewLifecycleOwner(), - scrollPosition -> { - if (scrollPosition != -1) { - listView.scrollToPosition( - Math.min(scrollPosition + 4, adapter.getItemCount() - 1)); - adapter.notifyItemChanged(scrollPosition); - } - }); - } + if (fileName != null) { + int scrollPosition = mainFragmentViewModel.getScrollPosition(fileName); + if (scrollPosition != -1) { + requireMainActivity().setScrollToFileName(null); + listView.scrollToPosition(Math.min(scrollPosition + 4, adapter.getItemCount() - 1)); + adapter.notifyItemChanged(scrollPosition); + } + } }); } diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt index 69b61e42cb..88308a3bec 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt @@ -228,20 +228,14 @@ class MainFragmentViewModel : ViewModel() { /** * Get the position of an item */ - fun getScrollPosition(title: String): MutableLiveData { - val mutableLiveData: MutableLiveData = MutableLiveData(-1) - - viewModelScope.launch(Dispatchers.IO) { - adapterListItems?.forEachIndexed { index, item -> - if (item.layoutElementParcelable != null && - item.layoutElementParcelable?.title.equals(title) - ) { - item.setChecked(true) - mutableLiveData.postValue(index) - } + fun getScrollPosition(title: String): Int { + adapterListItems?.forEachIndexed { index, item -> + if (item.layoutElementParcelable?.title == title) { + item.setChecked(true) + return index } } - return mutableLiveData + return -1 } /**