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..436f7d5e47 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,6 +42,11 @@ object StoragePermissionHelper { */ @JvmStatic fun grantManageStoragePermission() { + // Only need to run on Androids >= R + if (Build.VERSION.SDK_INT < VERSION_CODES.R) { + return + } + // Ensure that an activity that has the dialog is launched ActivityScenario.launch(MainActivity::class.java) @@ -47,9 +54,9 @@ object StoragePermissionHelper { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val grantPermissionHeader = amazeResources.getString(R.string.grantper) - if (device.hasObject(By.text(grantPermissionExplanation))) { + if (device.hasObject(By.text(grantPermissionHeader))) { // First press Amaze's grant button onView(withText(R.string.grant)).perform(click()) 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..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 @@ -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,19 +43,20 @@ 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 -import org.junit.Assert.fail import org.junit.Before 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: String = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule @@ -93,6 +94,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() && file.length() > 0L + } + } + /** * Test whether the exported file contains the expected preference values */ @@ -103,59 +117,71 @@ 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) + + lateinit var preferenceSnapshot: Map - activityScenario.onActivity { - it.supportFragmentManager.beginTransaction() + 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") + val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - assertTrue(tempFile.exists()) + assertTrue(tempFile.exists()) + } - onView(withId(R.id.home)).perform(ViewActions.click()) + // 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()) - assertTrue(exportFile.exists()) - - activityScenario.onActivity { preferencesActivity -> - val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) - 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) + // 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 inputString = + exportFile + .inputStream() + .bufferedReader() + .use { + it.readText() + } + + val type = object : TypeToken>() {}.type + + // TODO This breaks the exported file's types, all Numbers get converted to Double + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) + + for ((key, value) in preferenceSnapshot) { + val importedValue = importMap[key] + + 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() } /** @@ -172,7 +198,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, @@ -204,13 +238,14 @@ 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)) } } + + activityScenario.close() } private fun checkPrefEqual( @@ -223,15 +258,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) 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..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 @@ -1,23 +1,46 @@ +/* + * 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.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 +51,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,24 +72,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 -> + rotateScreen(scenario) + + swipeToItem(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() + } } } } @@ -80,13 +101,14 @@ 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 -> + // First perform the swipe action + swipeToItem(scenario, 1) + // Then force a configuration change by rotating the screen + rotateScreen(scenario) + rotateScreen(scenario) + awaitCurrentItem(scenario, 1) + } } /** @@ -94,16 +116,17 @@ 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 -> + // Perform rapid tab switches + repeat(10) { + swipeToItem(scenario, 1) + swipeToItem(scenario, 0) + } - // Force a save state by rotating - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + // Then force a save state by rotating + rotateScreen(scenario) + awaitCurrentItem(scenario, 0) + } } /** @@ -111,24 +134,161 @@ 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 -> + swipeToItem(scenario, 1) + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + 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) + } + } + + 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 } - // Force state save through configuration change - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + return requireNotNull(tabFragment) + } + + // 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) + + 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, + ) { + await().pollDelay(50, TimeUnit.MILLISECONDS).atMost(100, TimeUnit.MILLISECONDS).until { + var currentItem = -1 + + runCatching { + scenario.onActivity { activity -> + currentItem = activity.findViewById(R.id.pager).currentItem + } + } + + currentItem == index + } } -} +}