From feebe138fc466389a4dde12ea91be0736709c488 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 5 Aug 2026 23:52:59 +0800 Subject: [PATCH 1/4] Fix Espresso tests failing on #4692 --- .../test/StoragePermissionHelper.kt | 51 ++--- .../ui/fragments/BackupPrefsFragmentTest.kt | 116 ++++++----- .../ui/fragments/TabFragmentTest.kt | 180 ++++++++++++------ 3 files changed, 222 insertions(+), 125 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt index 62bca738ed..59c455424e 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -21,6 +21,8 @@ package com.amaze.filemanager.test import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -40,34 +42,37 @@ object StoragePermissionHelper { */ @JvmStatic fun grantManageStoragePermission() { - // Ensure that an activity that has the dialog is launched - ActivityScenario.launch(MainActivity::class.java) + // Only need to run on Androids >= R + if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { + // Ensure that an activity that has the dialog is launched + ActivityScenario.launch(MainActivity::class.java) - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) + val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) - if (device.hasObject(By.text(grantPermissionExplanation))) { - // First press Amaze's grant button - onView(withText(R.string.grant)).perform(click()) + if (device.hasObject(By.text(grantPermissionExplanation))) { + // First press Amaze's grant button + onView(withText(R.string.grant)).perform(click()) - // Identifier names are taken here: - // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml - val resources = context.packageManager.getResourcesForApplication("com.android.settings") - val resId = - resources.getIdentifier( - "permit_manage_external_storage", - "string", - "com.android.settings", - ) - val permitManageExternalStorage = resources.getString(resId) + // Identifier names are taken here: + // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml + val resources = context.packageManager.getResourcesForApplication("com.android.settings") + val resId = + resources.getIdentifier( + "permit_manage_external_storage", + "string", + "com.android.settings", + ) + val permitManageExternalStorage = resources.getString(resId) - val grantToggle = - device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) - grantToggle.click() - device.pressBack() + val grantToggle = + device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) + grantToggle.click() + device.pressBack() + } } } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 1e831fec2f..5cff15bb59 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -27,13 +27,13 @@ import android.content.SharedPreferences import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU +import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.preference.PreferenceManager import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule @@ -43,6 +43,7 @@ import com.amaze.filemanager.ui.activities.PreferencesActivity import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import org.awaitility.Awaitility.await import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -52,10 +53,11 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.File +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class BackupPrefsFragmentTest { - var storagePath = "/storage/emulated/0" + var storagePath = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule @@ -93,6 +95,19 @@ class BackupPrefsFragmentTest { import(exportFile) } + /** + * Waits (with a timeout) for the given file to exist, since some writes to storage happen + * asynchronously on a background thread. + */ + private fun waitForFile( + file: File, + timeoutSeconds: Long = 5L, + ) { + await().atMost(timeoutSeconds, TimeUnit.SECONDS).until { + file.exists() + } + } + /** * Test whether the exported file contains the expected preference values */ @@ -103,59 +118,70 @@ class BackupPrefsFragmentTest { val backupPrefsFragment = BackupPrefsFragment() val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) - activityScenario.moveToState(Lifecycle.State.STARTED) + // Espresso requires an activity to be RESUMED to dispatch view actions/clicks. + activityScenario.moveToState(Lifecycle.State.RESUMED) - activityScenario.onActivity { - it.supportFragmentManager.beginTransaction() + lateinit var preferences: SharedPreferences + + activityScenario.onActivity { preferencesActivity -> + preferencesActivity.supportFragmentManager.beginTransaction() .add(backupPrefsFragment, null) .commitNow() backupPrefsFragment.exportPrefs() - } - - val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - assertTrue(tempFile.exists()) - - onView(withId(R.id.home)).perform(ViewActions.click()) - onView(withText(R.string.save)).perform(ViewActions.click()) - - assertTrue(exportFile.exists()) - - activityScenario.onActivity { preferencesActivity -> - val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) - val preferenceMap: Map = preferences.all + val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - val inputString = - exportFile - .inputStream() - .bufferedReader() - .use { - it.readText() - } + assertTrue(tempFile.exists()) - val type = object : TypeToken>() {}.type + preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) + } - val importMap: Map = - GsonBuilder() - .create() - .fromJson( - inputString, - type, - ) - - for ((key, value) in preferenceMap) { - val importedValue = importMap[key] - val mapValue = - if (importedValue != null && importedValue::class.simpleName.equals("Double")) { - (importedValue as Double).toInt() // since Gson parses Integer as Double - } else { - importedValue - } + // Espresso's onView().perform() must run on the instrumentation/test thread, never from + // inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread). + // Espresso internally synchronizes with the UI thread itself; calling it from the UI + // thread can deadlock or throw IllegalStateException. + // exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar + // with a "Save" action; that is the only view action needed here. + onView(withText(R.string.save)).perform(ViewActions.click()) - assertEquals("Difference found at key $key", value, mapValue) - } + // The actual write to storagePath happens asynchronously (RxJava) after the "Save" click + // and after MainActivity finishes, so poll for the file instead of asserting immediately. + waitForFile(exportFile) + + val preferenceMap: Map = preferences.all + + val inputString = + exportFile + .inputStream() + .bufferedReader() + .use { + it.readText() + } + + val type = object : TypeToken>() {}.type + + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) + + for ((key, value) in preferenceMap) { + val importedValue = importMap[key] + val mapValue = + if (importedValue != null && importedValue::class.simpleName.equals("Double")) { + (importedValue as Double).toInt() // since Gson parses Integer as Double + } else { + importedValue + } + + assertEquals("Difference found at key $key", value, mapValue) } + + activityScenario.close() } /** @@ -211,6 +237,8 @@ class BackupPrefsFragmentTest { assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value)) } } + + activityScenario.close() } private fun checkPrefEqual( diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index b26a65854a..b3d5c88c9b 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -1,23 +1,20 @@ package com.amaze.filemanager.ui.fragments -import android.content.pm.ActivityInfo import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.swipeLeft -import androidx.test.espresso.action.ViewActions.swipeRight -import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.ActivityTestRule import androidx.test.rule.GrantPermissionRule +import androidx.viewpager2.widget.ViewPager2 import com.amaze.filemanager.R import com.amaze.filemanager.test.StoragePermissionHelper import com.amaze.filemanager.ui.activities.MainActivity +import org.awaitility.Awaitility.await import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit /** * Tests for [TabFragment] functionality, mainly for @@ -28,9 +25,6 @@ import org.junit.runner.RunWith @Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) class TabFragmentTest { - @get:Rule - val activityRule = ActivityTestRule(MainActivity::class.java) - @Rule @JvmField val storagePermissionRule: GrantPermissionRule = @@ -52,25 +46,25 @@ class TabFragmentTest { } /** - * This test causes a rotation to happen while the MainFragment detaches, to check if it - * fails. This could happen in reality, but should be very rare + * This test saves state while a MainFragment is detached. */ @Test fun testFragmentStateSavingDuringDetachment() { - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment - - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.forEach { detach(it) } - commit() + withScenario { scenario -> + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment + + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } } + + recreateActivity(scenario) } } @@ -80,13 +74,11 @@ class TabFragmentTest { */ @Test fun testFragmentStateSavingDuringConfigChange() { - // First perform the swipe action - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Force a configuration change by rotating the screen - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - Thread.sleep(1000) // Give time for the rotation to complete - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + withScenario { scenario -> + setCurrentItem(scenario, 1) + recreateActivity(scenario) + awaitCurrentItem(scenario, 1) + } } /** @@ -94,16 +86,15 @@ class TabFragmentTest { */ @Test fun testRapidTabSwitchingAndStateSaving() { - // Perform rapid tab switches - repeat(10) { - onView(withId(R.id.pager)).perform(swipeLeft()) - Thread.sleep(100) // Small delay to ensure swipe completes - onView(withId(R.id.pager)).perform(swipeRight()) - Thread.sleep(100) // Small delay to ensure swipe completes - } + withScenario { scenario -> + repeat(10) { + setCurrentItem(scenario, 1) + setCurrentItem(scenario, 0) + } - // Force a save state by rotating - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + recreateActivity(scenario) + awaitCurrentItem(scenario, 0) + } } /** @@ -111,24 +102,97 @@ class TabFragmentTest { */ @Test fun testFragmentDetachmentAndStateSaving() { - // First switch to a different tab - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment - - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.firstOrNull()?.let { detach(it) } - commit() + withScenario { scenario -> + setCurrentItem(scenario, 1) + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment + + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } + } + + recreateActivity(scenario) + } + } + + private fun withScenario(testBody: (ActivityScenario) -> Unit) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + awaitPager(scenario) + testBody(scenario) + } + } + + private fun awaitPager(scenario: ActivityScenario): ViewPager2 { + var pager: ViewPager2? = null + + await().atMost(10, TimeUnit.SECONDS).until { + scenario.onActivity { activity -> + pager = activity.findViewById(R.id.pager) + } + + pager != null + } + + return requireNotNull(pager) + } + + private fun awaitTabFragment(scenario: ActivityScenario): TabFragment { + var tabFragment: TabFragment? = null + + await().atMost(10, TimeUnit.SECONDS).until { + runCatching { + scenario.onActivity { activity -> + tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as? TabFragment + } + } + + tabFragment?.view != null && tabFragment?.fragments?.isNotEmpty() == true + } + + return requireNotNull(tabFragment) + } + + private fun setCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + awaitPager(scenario) + + scenario.onActivity { activity -> + activity.findViewById(R.id.pager).setCurrentItem(index, false) + } + + awaitCurrentItem(scenario, index) + } + + private fun awaitCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + await().atMost(5, TimeUnit.SECONDS).until { + var currentItem = -1 + + runCatching { + scenario.onActivity { activity -> + currentItem = activity.findViewById(R.id.pager).currentItem + } } + + currentItem == index } + } - // Force state save through configuration change - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + private fun recreateActivity(scenario: ActivityScenario) { + scenario.recreate() + awaitPager(scenario) + awaitTabFragment(scenario) } -} +} From b7c1d308f9abd94808c0ca7d62a8f4c1bb862cb0 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 6 Aug 2026 23:45:24 +0800 Subject: [PATCH 2/4] Changes per PR feedback - TabFragmentTest use back actions to swipe instead of programmatically --- .../ui/fragments/BackupPrefsFragmentTest.kt | 2 +- .../ui/fragments/TabFragmentTest.kt | 123 +++++++++++++++--- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 5cff15bb59..08cbbaab58 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -57,7 +57,7 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class BackupPrefsFragmentTest { - var storagePath = Environment.getExternalStorageDirectory().absolutePath + var storagePath: String = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index b3d5c88c9b..520d842b43 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -1,8 +1,34 @@ +/* + * Copyright (C) 2014-2025 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.fragments +import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.action.ViewActions.swipeRight +import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import androidx.viewpager2.widget.ViewPager2 @@ -51,6 +77,7 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringDetachment() { withScenario { scenario -> + swipeToItem(scenario, 1) awaitTabFragment(scenario) scenario.onActivity { activity -> @@ -64,7 +91,7 @@ class TabFragmentTest { } } - recreateActivity(scenario) + rotateScreen(scenario) } } @@ -75,8 +102,8 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringConfigChange() { withScenario { scenario -> - setCurrentItem(scenario, 1) - recreateActivity(scenario) + swipeToItem(scenario, 1) + rotateScreen(scenario) awaitCurrentItem(scenario, 1) } } @@ -88,11 +115,11 @@ class TabFragmentTest { fun testRapidTabSwitchingAndStateSaving() { withScenario { scenario -> repeat(10) { - setCurrentItem(scenario, 1) - setCurrentItem(scenario, 0) + swipeToItem(scenario, 1) + swipeToItem(scenario, 0) } - recreateActivity(scenario) + rotateScreen(scenario) awaitCurrentItem(scenario, 0) } } @@ -103,7 +130,7 @@ class TabFragmentTest { @Test fun testFragmentDetachmentAndStateSaving() { withScenario { scenario -> - setCurrentItem(scenario, 1) + swipeToItem(scenario, 1) awaitTabFragment(scenario) scenario.onActivity { activity -> @@ -117,7 +144,7 @@ class TabFragmentTest { } } - recreateActivity(scenario) + rotateScreen(scenario) } } @@ -160,19 +187,87 @@ class TabFragmentTest { return requireNotNull(tabFragment) } - private fun setCurrentItem( + // Swipe to the other tab in the ViewPager2. + // Index 0 is the first tab, index 1 is the second tab. + private fun swipeToItem( scenario: ActivityScenario, index: Int, ) { awaitPager(scenario) - scenario.onActivity { activity -> - activity.findViewById(R.id.pager).setCurrentItem(index, false) + when (index) { + 0 -> onView(withId(R.id.pager)).perform(swipeRight()) + 1 -> onView(withId(R.id.pager)).perform(swipeLeft()) + else -> error("Unsupported pager index: $index") } awaitCurrentItem(scenario, index) } + private fun rotateScreen(scenario: ActivityScenario) { + val initialOrientation = + currentOrientation(scenario).takeIf { + it == Configuration.ORIENTATION_LANDSCAPE || it == Configuration.ORIENTATION_PORTRAIT + } ?: Configuration.ORIENTATION_PORTRAIT + val rotatedRequestedOrientation = + if (initialOrientation == Configuration.ORIENTATION_LANDSCAPE) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + setRequestedOrientation(scenario, rotatedRequestedOrientation) + awaitOrientation(scenario, orientationForRequest(rotatedRequestedOrientation)) + + setRequestedOrientation(scenario, orientationRequestFor(initialOrientation)) + awaitOrientation(scenario, initialOrientation) + + awaitPager(scenario) + awaitTabFragment(scenario) + } + + private fun setRequestedOrientation( + scenario: ActivityScenario, + requestedOrientation: Int, + ) { + scenario.onActivity { activity -> + activity.requestedOrientation = requestedOrientation + } + } + + private fun currentOrientation(scenario: ActivityScenario): Int { + var orientation = Configuration.ORIENTATION_UNDEFINED + + scenario.onActivity { activity -> + orientation = activity.resources.configuration.orientation + } + + return orientation + } + + private fun orientationForRequest(requestedOrientation: Int): Int = + when (requestedOrientation) { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> Configuration.ORIENTATION_LANDSCAPE + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> Configuration.ORIENTATION_PORTRAIT + else -> Configuration.ORIENTATION_UNDEFINED + } + + private fun orientationRequestFor(orientation: Int): Int = + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + + private fun awaitOrientation( + scenario: ActivityScenario, + expectedOrientation: Int, + ) { + await().atMost(10, TimeUnit.SECONDS).until { + currentOrientation(scenario) == expectedOrientation + } + } + private fun awaitCurrentItem( scenario: ActivityScenario, index: Int, @@ -189,10 +284,4 @@ class TabFragmentTest { currentItem == index } } - - private fun recreateActivity(scenario: ActivityScenario) { - scenario.recreate() - awaitPager(scenario) - awaitTabFragment(scenario) - } } From 18b58dadd4c979a28531f2b2daad6ab80cff75c4 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Fri, 7 Aug 2026 16:28:50 +0900 Subject: [PATCH 3/4] Fix failing BackupPrefsFragmentTest --- .../ui/fragments/BackupPrefsFragmentTest.kt | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 08cbbaab58..256e882ee5 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -47,7 +47,6 @@ import org.awaitility.Awaitility.await import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Before import org.junit.Rule import org.junit.Test @@ -104,7 +103,7 @@ class BackupPrefsFragmentTest { timeoutSeconds: Long = 5L, ) { await().atMost(timeoutSeconds, TimeUnit.SECONDS).until { - file.exists() + file.exists() && file.length() > 0L } } @@ -121,20 +120,21 @@ class BackupPrefsFragmentTest { // Espresso requires an activity to be RESUMED to dispatch view actions/clicks. activityScenario.moveToState(Lifecycle.State.RESUMED) - lateinit var preferences: SharedPreferences + lateinit var preferenceSnapshot: Map activityScenario.onActivity { preferencesActivity -> preferencesActivity.supportFragmentManager.beginTransaction() .add(backupPrefsFragment, null) .commitNow() + val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) + preferenceSnapshot = HashMap(preferences.all) + backupPrefsFragment.exportPrefs() val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") assertTrue(tempFile.exists()) - - preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) } // Espresso's onView().perform() must run on the instrumentation/test thread, never from @@ -149,8 +149,6 @@ class BackupPrefsFragmentTest { // and after MainActivity finishes, so poll for the file instead of asserting immediately. waitForFile(exportFile) - val preferenceMap: Map = preferences.all - val inputString = exportFile .inputStream() @@ -169,14 +167,9 @@ class BackupPrefsFragmentTest { type, ) - for ((key, value) in preferenceMap) { + for ((key, value) in preferenceSnapshot) { val importedValue = importMap[key] - val mapValue = - if (importedValue != null && importedValue::class.simpleName.equals("Double")) { - (importedValue as Double).toInt() // since Gson parses Integer as Double - } else { - importedValue - } + val mapValue = normalizeImportedValue(importedValue, value) assertEquals("Difference found at key $key", value, mapValue) } @@ -198,7 +191,15 @@ class BackupPrefsFragmentTest { .add(backupPrefsFragment, null) .commitNow() - javaClass.getResourceAsStream("/$fileName")?.copyTo(exportFile.outputStream()) + val resourceStream = + requireNotNull(javaClass.getResourceAsStream("/$fileName")) { + "Missing test resource /$fileName" + } + resourceStream.use { input -> + exportFile.outputStream().use { output -> + input.copyTo(output) + } + } backupPrefsFragment.onActivityResult( BackupPrefsFragment.IMPORT_BACKUP_FILE, @@ -230,9 +231,8 @@ class BackupPrefsFragmentTest { assertFalse(preferenceMap.containsKey(null)) for ((k, v) in preferenceMap) { - // This cast tells the kotlin type checker that fail() never returns - val key = k ?: (fail() as Nothing) - val value = v ?: (fail() as Nothing) + val key = requireNotNull(k) { "Preference key unexpectedly null" } + val value = requireNotNull(v) { "Preference value unexpectedly null for $key" } assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value)) } @@ -251,15 +251,14 @@ class BackupPrefsFragmentTest { "Boolean" -> return importMap[key] as Boolean == preferences.getBoolean(key, false) "Float" -> - importMap[key] as Float == + (importMap[key] as Number).toFloat() == preferences.getFloat(key, 0f) "Int" -> { - // since Gson parses Integer as Double - val toInt = (importMap[key] as Double).toInt() + val toInt = (importMap[key] as Number).toInt() return toInt == preferences.getInt(key, 0) } - "Long" -> return importMap[key] as Long == + "Long" -> return (importMap[key] as Number).toLong() == preferences.getLong(key, 0L) "String" -> return importMap[key] as String == preferences.getString(key, null) @@ -268,4 +267,20 @@ class BackupPrefsFragmentTest { } return false } + + private fun normalizeImportedValue( + importedValue: Any?, + expectedValue: Any?, + ): Any? { + if (importedValue !is Number || expectedValue !is Number) { + return importedValue + } + return when (expectedValue) { + is Int -> importedValue.toInt() + is Long -> importedValue.toLong() + is Float -> importedValue.toFloat() + is Double -> importedValue.toDouble() + else -> importedValue + } + } } From 10c422268586334edaa164c3000f1fdc42a4dc59 Mon Sep 17 00:00:00 2001 From: EmmanuelMess Date: Sun, 9 Aug 2026 14:26:17 -0300 Subject: [PATCH 4/4] More test fixes --- .../test/StoragePermissionHelper.kt | 52 ++++++++++--------- .../ui/fragments/BackupPrefsFragmentTest.kt | 27 ++++------ .../ui/fragments/TabFragmentTest.kt | 13 +++-- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt index 59c455424e..436f7d5e47 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -43,36 +43,38 @@ object StoragePermissionHelper { @JvmStatic fun grantManageStoragePermission() { // Only need to run on Androids >= R - if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { - // Ensure that an activity that has the dialog is launched - ActivityScenario.launch(MainActivity::class.java) + if (Build.VERSION.SDK_INT < VERSION_CODES.R) { + return + } + + // Ensure that an activity that has the dialog is launched + ActivityScenario.launch(MainActivity::class.java) - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) + val grantPermissionHeader = amazeResources.getString(R.string.grantper) - if (device.hasObject(By.text(grantPermissionExplanation))) { - // First press Amaze's grant button - onView(withText(R.string.grant)).perform(click()) + if (device.hasObject(By.text(grantPermissionHeader))) { + // First press Amaze's grant button + onView(withText(R.string.grant)).perform(click()) - // Identifier names are taken here: - // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml - val resources = context.packageManager.getResourcesForApplication("com.android.settings") - val resId = - resources.getIdentifier( - "permit_manage_external_storage", - "string", - "com.android.settings", - ) - val permitManageExternalStorage = resources.getString(resId) + // Identifier names are taken here: + // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml + val resources = context.packageManager.getResourcesForApplication("com.android.settings") + val resId = + resources.getIdentifier( + "permit_manage_external_storage", + "string", + "com.android.settings", + ) + val permitManageExternalStorage = resources.getString(resId) - val grantToggle = - device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) - grantToggle.click() - device.pressBack() - } + val grantToggle = + device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) + grantToggle.click() + device.pressBack() } } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 256e882ee5..1a6a749c8b 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -159,6 +159,7 @@ class BackupPrefsFragmentTest { val type = object : TypeToken>() {}.type + // TODO This breaks the exported file's types, all Numbers get converted to Double val importMap: Map = GsonBuilder() .create() @@ -169,9 +170,15 @@ class BackupPrefsFragmentTest { for ((key, value) in preferenceSnapshot) { val importedValue = importMap[key] - val mapValue = normalizeImportedValue(importedValue, value) - assertEquals("Difference found at key $key", value, mapValue) + if (value is Number) { + // HACK GsonBuilder().create().fromJson() breaks Number types + assertEquals("Difference found at key $key", value.toDouble(), importedValue as Double, 0.1) + } else { + assertEquals("Different type at key $key", value?.javaClass, importedValue?.javaClass) + + assertEquals("Difference found at key $key", value, importedValue) + } } activityScenario.close() @@ -267,20 +274,4 @@ class BackupPrefsFragmentTest { } return false } - - private fun normalizeImportedValue( - importedValue: Any?, - expectedValue: Any?, - ): Any? { - if (importedValue !is Number || expectedValue !is Number) { - return importedValue - } - return when (expectedValue) { - is Int -> importedValue.toInt() - is Long -> importedValue.toLong() - is Float -> importedValue.toFloat() - is Double -> importedValue.toDouble() - else -> importedValue - } - } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index 520d842b43..d9e675b581 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -77,6 +77,8 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringDetachment() { withScenario { scenario -> + rotateScreen(scenario) + swipeToItem(scenario, 1) awaitTabFragment(scenario) @@ -90,8 +92,6 @@ class TabFragmentTest { commitNow() } } - - rotateScreen(scenario) } } @@ -102,7 +102,10 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringConfigChange() { withScenario { scenario -> + // First perform the swipe action swipeToItem(scenario, 1) + // Then force a configuration change by rotating the screen + rotateScreen(scenario) rotateScreen(scenario) awaitCurrentItem(scenario, 1) } @@ -114,11 +117,13 @@ class TabFragmentTest { @Test fun testRapidTabSwitchingAndStateSaving() { withScenario { scenario -> + // Perform rapid tab switches repeat(10) { swipeToItem(scenario, 1) swipeToItem(scenario, 0) } + // Then force a save state by rotating rotateScreen(scenario) awaitCurrentItem(scenario, 0) } @@ -138,12 +143,14 @@ class TabFragmentTest { activity.supportFragmentManager .findFragmentById(R.id.content_frame) as TabFragment + // Detach TabFragment through FragmentManager activity.supportFragmentManager.beginTransaction().apply { tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } commitNow() } } + // Force state save through configuration change rotateScreen(scenario) } } @@ -272,7 +279,7 @@ class TabFragmentTest { scenario: ActivityScenario, index: Int, ) { - await().atMost(5, TimeUnit.SECONDS).until { + await().pollDelay(50, TimeUnit.MILLISECONDS).atMost(100, TimeUnit.MILLISECONDS).until { var currentItem = -1 runCatching {