@@ -27,6 +27,7 @@ import android.content.SharedPreferences
2727import android.net.Uri
2828import android.os.Build.VERSION.SDK_INT
2929import android.os.Build.VERSION_CODES.TIRAMISU
30+ import android.os.Environment
3031import android.util.Log
3132import androidx.lifecycle.Lifecycle
3233import androidx.preference.PreferenceManager
@@ -52,18 +53,20 @@ import com.amaze.filemanager.ui.activities.PreferencesActivity
5253import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment
5354import com.google.gson.GsonBuilder
5455import com.google.gson.reflect.TypeToken
56+ import org.awaitility.Awaitility.await
5557import org.junit.Assert.assertEquals
5658import org.junit.Assert.assertFalse
5759import org.junit.Assert.assertTrue
58- import org.junit.Assert.fail
5960import org.junit.Before
6061import org.junit.Rule
6162import org.junit.Test
6263import org.junit.runner.RunWith
6364import java.io.File
65+ import java.util.concurrent.TimeUnit
6466
6567@RunWith(AndroidJUnit4 ::class )
6668class BackupPrefsFragmentTest {
69+ var storagePath: String = Environment .getExternalStorageDirectory().absolutePath
6770 var fileName = " amaze_backup.json"
6871
6972 @Rule
@@ -112,6 +115,19 @@ class BackupPrefsFragmentTest {
112115 import(exportFile)
113116 }
114117
118+ /* *
119+ * Waits (with a timeout) for the given file to exist, since some writes to storage happen
120+ * asynchronously on a background thread.
121+ */
122+ private fun waitForFile (
123+ file : File ,
124+ timeoutSeconds : Long = 5L,
125+ ) {
126+ await().atMost(timeoutSeconds, TimeUnit .SECONDS ).until {
127+ file.exists() && file.length() > 0L
128+ }
129+ }
130+
115131 /* *
116132 * Test whether the exported file contains the expected preference values
117133 */
@@ -120,6 +136,7 @@ class BackupPrefsFragmentTest {
120136 exportFile : File ,
121137 ) {
122138 val activityScenario = ActivityScenario .launch(PreferencesActivity ::class .java)
139+ // Espresso requires an activity to be RESUMED to dispatch view actions/clicks.
123140 activityScenario.moveToState(Lifecycle .State .RESUMED )
124141
125142 onView(withText(R .string.backup)).perform(click())
@@ -144,44 +161,58 @@ class BackupPrefsFragmentTest {
144161 onView(withText(R .string.home)).perform(click())
145162 }
146163
147- onView(withText(R .string.save)).perform(click())
148-
149- assertTrue(exportFile.exists())
164+ lateinit var preferenceSnapshot: Map <String ?, * >
150165
151166 activityScenario.onActivity { preferencesActivity ->
152167 val preferences = PreferenceManager .getDefaultSharedPreferences(preferencesActivity)
153- val preferenceMap: Map <String ?, * > = preferences.all
154-
155- val inputString =
156- exportFile
157- .inputStream()
158- .bufferedReader()
159- .use {
160- it.readText()
161- }
162-
163- val type = object : TypeToken <Map <String ?, * >>() {}.type
168+ preferenceSnapshot = HashMap (preferences.all)
169+ }
164170
165- val importMap: Map <String ?, * > =
166- GsonBuilder ()
167- .create()
168- .fromJson(
169- inputString,
170- type,
171- )
172-
173- for ((key, value) in preferenceMap) {
174- val importedValue = importMap[key]
175- val mapValue =
176- if (importedValue != null && importedValue::class .simpleName.equals(" Double" )) {
177- (importedValue as Double ).toInt() // since Gson parses Integer as Double
178- } else {
179- importedValue
180- }
171+ // Espresso's onView().perform() must run on the instrumentation/test thread, never from
172+ // inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread).
173+ // Espresso internally synchronizes with the UI thread itself; calling it from the UI
174+ // thread can deadlock or throw IllegalStateException.
175+ // exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar
176+ // with a "Save" action; that is the only view action needed here.
177+ onView(withText(R .string.save)).perform(click())
181178
182- assertEquals(" Difference found at key $key " , value, mapValue)
179+ // The actual write to storagePath happens asynchronously (RxJava) after the "Save" click
180+ // and after MainActivity finishes, so poll for the file instead of asserting immediately.
181+ waitForFile(exportFile)
182+
183+ val inputString =
184+ exportFile
185+ .inputStream()
186+ .bufferedReader()
187+ .use {
188+ it.readText()
189+ }
190+
191+ val type = object : TypeToken <Map <String ?, * >>() {}.type
192+
193+ // TODO This breaks the exported file's types, all Numbers get converted to Double
194+ val importMap: Map <String ?, * > =
195+ GsonBuilder ()
196+ .create()
197+ .fromJson(
198+ inputString,
199+ type,
200+ )
201+
202+ for ((key, value) in preferenceSnapshot) {
203+ val importedValue = importMap[key]
204+
205+ if (value is Number ) {
206+ // HACK GsonBuilder().create().fromJson() breaks Number types
207+ assertEquals(" Difference found at key $key " , value.toDouble(), importedValue as Double , 0.1 )
208+ } else {
209+ assertEquals(" Different type at key $key " , value?.javaClass, importedValue?.javaClass)
210+
211+ assertEquals(" Difference found at key $key " , value, importedValue)
183212 }
184213 }
214+
215+ activityScenario.close()
185216 }
186217
187218 /* *
@@ -228,13 +259,14 @@ class BackupPrefsFragmentTest {
228259 assertFalse(preferenceMap.containsKey(null ))
229260
230261 for ((k, v) in preferenceMap) {
231- // This cast tells the kotlin type checker that fail() never returns
232- val key = k ? : (fail() as Nothing )
233- val value = v ? : (fail() as Nothing )
262+ val key = requireNotNull(k) { " Preference key unexpectedly null" }
263+ val value = requireNotNull(v) { " Preference value unexpectedly null for $key " }
234264
235265 assertTrue(" checkPrefEqual($key ) failed" , checkPrefEqual(preferences, importMap, key, value))
236266 }
237267 }
268+
269+ activityScenario.close()
238270 }
239271
240272 private fun checkPrefEqual (
@@ -247,15 +279,14 @@ class BackupPrefsFragmentTest {
247279 " Boolean" -> return importMap[key] as Boolean ==
248280 preferences.getBoolean(key, false )
249281 " Float" ->
250- importMap[key] as Float ==
282+ ( importMap[key] as Number ).toFloat() ==
251283 preferences.getFloat(key, 0f )
252284 " Int" -> {
253- // since Gson parses Integer as Double
254- val toInt = (importMap[key] as Double ).toInt()
285+ val toInt = (importMap[key] as Number ).toInt()
255286
256287 return toInt == preferences.getInt(key, 0 )
257288 }
258- " Long" -> return importMap[key] as Long ==
289+ " Long" -> return ( importMap[key] as Number ).toLong() ==
259290 preferences.getLong(key, 0L )
260291 " String" -> return importMap[key] as String ==
261292 preferences.getString(key, null )
0 commit comments