@@ -27,13 +27,13 @@ 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 androidx.lifecycle.Lifecycle
3132import androidx.preference.PreferenceManager
3233import androidx.test.core.app.ActivityScenario
3334import androidx.test.core.app.ApplicationProvider
3435import androidx.test.espresso.Espresso.onView
3536import androidx.test.espresso.action.ViewActions
36- import androidx.test.espresso.matcher.ViewMatchers.withId
3737import androidx.test.espresso.matcher.ViewMatchers.withText
3838import androidx.test.ext.junit.runners.AndroidJUnit4
3939import androidx.test.rule.GrantPermissionRule
@@ -55,7 +55,7 @@ import java.io.File
5555
5656@RunWith(AndroidJUnit4 ::class )
5757class BackupPrefsFragmentTest {
58- var storagePath = " /storage/emulated/0 "
58+ var storagePath = Environment .getExternalStorageDirectory().absolutePath
5959 var fileName = " amaze_backup.json"
6060
6161 @Rule
@@ -93,6 +93,23 @@ class BackupPrefsFragmentTest {
9393 import(exportFile)
9494 }
9595
96+ /* *
97+ * Waits (with a timeout) for the given file to exist, since some writes to storage happen
98+ * asynchronously on a background thread.
99+ */
100+ private fun waitForFile (
101+ file : File ,
102+ timeoutMillis : Long = 5000L,
103+ ) {
104+ val startTime = System .currentTimeMillis()
105+ while (! file.exists()) {
106+ if (System .currentTimeMillis() - startTime > timeoutMillis) {
107+ fail(" Timed out waiting for file to be created: ${file.absolutePath} " )
108+ }
109+ Thread .sleep(100L )
110+ }
111+ }
112+
96113 /* *
97114 * Test whether the exported file contains the expected preference values
98115 */
@@ -103,58 +120,67 @@ class BackupPrefsFragmentTest {
103120 val backupPrefsFragment = BackupPrefsFragment ()
104121 val activityScenario = ActivityScenario .launch(PreferencesActivity ::class .java)
105122
106- activityScenario.moveToState(Lifecycle .State .STARTED )
123+ // Espresso requires an activity to be RESUMED to dispatch view actions/clicks.
124+ activityScenario.moveToState(Lifecycle .State .RESUMED )
125+
126+ lateinit var preferences: SharedPreferences
107127
108- activityScenario.onActivity {
109- it .supportFragmentManager.beginTransaction()
128+ activityScenario.onActivity { preferencesActivity ->
129+ preferencesActivity .supportFragmentManager.beginTransaction()
110130 .add(backupPrefsFragment, null )
111131 .commitNow()
112132
113133 backupPrefsFragment.exportPrefs()
114- }
115134
116- val tempFile = File (" ${context.cacheDir.absolutePath}${File .separator}$fileName " )
135+ val tempFile = File (" ${context.cacheDir.absolutePath}${File .separator}$fileName " )
117136
118- assertTrue(tempFile.exists())
119-
120- onView(withId(R .id.home)).perform(ViewActions .click())
121- onView(withText(R .string.save)).perform(ViewActions .click())
137+ assertTrue(tempFile.exists())
122138
123- assertTrue(exportFile.exists())
124-
125- activityScenario.onActivity { preferencesActivity ->
126- val preferences = PreferenceManager .getDefaultSharedPreferences(preferencesActivity)
127- val preferenceMap: Map <String ?, * > = preferences.all
128-
129- val inputString =
130- exportFile
131- .inputStream()
132- .bufferedReader()
133- .use {
134- it.readText()
135- }
136-
137- val type = object : TypeToken <Map <String ?, * >>() {}.type
139+ preferences = PreferenceManager .getDefaultSharedPreferences(preferencesActivity)
140+ }
138141
139- val importMap: Map <String ?, * > =
140- GsonBuilder ()
141- .create()
142- .fromJson(
143- inputString,
144- type,
145- )
146-
147- for ((key, value) in preferenceMap) {
148- val importedValue = importMap[key]
149- val mapValue =
150- if (importedValue != null && importedValue::class .simpleName.equals(" Double" )) {
151- (importedValue as Double ).toInt() // since Gson parses Integer as Double
152- } else {
153- importedValue
154- }
142+ // Espresso's onView().perform() must run on the instrumentation/test thread, never from
143+ // inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread).
144+ // Espresso internally synchronizes with the UI thread itself; calling it from the UI
145+ // thread can deadlock or throw IllegalStateException.
146+ // exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar
147+ // with a "Save" action; that is the only view action needed here.
148+ onView(withText(R .string.save)).perform(ViewActions .click())
155149
156- assertEquals(" Difference found at key $key " , value, mapValue)
157- }
150+ // The actual write to storagePath happens asynchronously (RxJava) after the "Save" click
151+ // and after MainActivity finishes, so poll for the file instead of asserting immediately.
152+ waitForFile(exportFile)
153+
154+ val preferenceMap: Map <String ?, * > = preferences.all
155+
156+ val inputString =
157+ exportFile
158+ .inputStream()
159+ .bufferedReader()
160+ .use {
161+ it.readText()
162+ }
163+
164+ val type = object : TypeToken <Map <String ?, * >>() {}.type
165+
166+ val importMap: Map <String ?, * > =
167+ GsonBuilder ()
168+ .create()
169+ .fromJson(
170+ inputString,
171+ type,
172+ )
173+
174+ for ((key, value) in preferenceMap) {
175+ val importedValue = importMap[key]
176+ val mapValue =
177+ if (importedValue != null && importedValue::class .simpleName.equals(" Double" )) {
178+ (importedValue as Double ).toInt() // since Gson parses Integer as Double
179+ } else {
180+ importedValue
181+ }
182+
183+ assertEquals(" Difference found at key $key " , value, mapValue)
158184 }
159185 }
160186
0 commit comments