From ad20e16d59053e5e3d5919f21bfc3b2ef24d7c02 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 14 Apr 2024 21:20:14 +0200 Subject: [PATCH 01/11] Handle the "alarm & reminders" permission added in API 31 --- .../android/reminders/RemindersScheduler.kt | 9 +++- .../ui/dialogs/TimestampDialogFragment.kt | 39 ++++++++++++++++- .../orgzly/android/ui/note/NoteFragment.kt | 42 +++++++++++++++++-- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/reminders/RemindersScheduler.kt b/app/src/main/java/com/orgzly/android/reminders/RemindersScheduler.kt index 4e8226938..de7ddd7e7 100644 --- a/app/src/main/java/com/orgzly/android/reminders/RemindersScheduler.kt +++ b/app/src/main/java/com/orgzly/android/reminders/RemindersScheduler.kt @@ -86,11 +86,16 @@ class RemindersScheduler @Inject constructor(val context: Application, val logs: } } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!alarmManager.canScheduleExactAlarms()) { + throw SecurityException("Missing permission to schedule alarm") + } + } + // TODO: Add preferences to control *how* to schedule the alarms if (hasTime) { if (AppPreferences.remindersUseAlarmClockForTodReminders(context)) { scheduleAlarmClock(alarmManager, intent, inMs, origin) - } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { scheduleExactAndAllowWhileIdle(alarmManager, intent, inMs, origin) @@ -174,4 +179,4 @@ class RemindersScheduler @Inject constructor(val context: Application, val logs: } private val TAG: String = RemindersScheduler::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/java/com/orgzly/android/ui/dialogs/TimestampDialogFragment.kt b/app/src/main/java/com/orgzly/android/ui/dialogs/TimestampDialogFragment.kt index e9aa00ebb..148ab5de6 100644 --- a/app/src/main/java/com/orgzly/android/ui/dialogs/TimestampDialogFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/dialogs/TimestampDialogFragment.kt @@ -3,7 +3,11 @@ package com.orgzly.android.ui.dialogs import android.app.DatePickerDialog import android.app.Dialog import android.app.TimePickerDialog +import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import android.text.format.DateFormat import android.view.LayoutInflater import android.view.View @@ -15,6 +19,7 @@ import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.ui.TimeType import com.orgzly.android.ui.util.KeyboardUtils +import com.orgzly.android.ui.util.getAlarmManager import com.orgzly.android.util.LogUtils import com.orgzly.android.util.UserTimeFormatter import com.orgzly.databinding.DialogTimestampBinding @@ -83,6 +88,9 @@ class TimestampDialogFragment : DialogFragment(), View.OnClickListener { binding.timePickerButton.setOnClickListener(this) binding.timeUsedCheckbox.setOnCheckedChangeListener { _, isChecked -> viewModel.setIsTimeUsed(isChecked) + if (isChecked) { + ensureAlarmPermissions() + } } binding.endTimePickerButton.setOnClickListener(this) @@ -116,7 +124,13 @@ class TimestampDialogFragment : DialogFragment(), View.OnClickListener { .setView(binding.root) .setPositiveButton(R.string.set) { _, _ -> val time = viewModel.getOrgDateTime() - listener?.onDateTimeSet(dialogId, noteIds, time) + if (time != null && time.hasTime()) { + if (isAlarmPermissionGranted()) { + listener?.onDateTimeSet(dialogId, noteIds, time) + } + } else { + listener?.onDateTimeSet(dialogId, noteIds, time) + } } .setNeutralButton(R.string.clear) { _, _ -> listener?.onDateTimeSet(dialogId, noteIds, null) @@ -127,6 +141,29 @@ class TimestampDialogFragment : DialogFragment(), View.OnClickListener { .show() } + private fun isAlarmPermissionGranted(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!requireContext().getAlarmManager().canScheduleExactAlarms()) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Alarms & reminders permission needed") + .setMessage("The app needs the \"alarms & reminders\" permission to set exact times for scheduled/deadline. Please grant the permission in the \"app info\" screen.") + .setPositiveButton(R.string.ok, null) + .show() + return false + } + } + return true + } + + private fun ensureAlarmPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!requireContext().getAlarmManager().canScheduleExactAlarms()) { + val uri = Uri.parse("package:" + BuildConfig.APPLICATION_ID) + activity?.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, uri)) + } + } + } + /** * Receives all dialog's clicks */ diff --git a/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt b/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt index e974924d6..db4f2b9bf 100644 --- a/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt @@ -3,13 +3,20 @@ package com.orgzly.android.ui.note import android.content.Context import android.content.Intent import android.graphics.Typeface +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import android.text.Editable import android.text.TextUtils import android.text.TextWatcher import android.text.method.LinkMovementMethod import android.util.Log -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.EditText import android.widget.TextView @@ -28,7 +35,13 @@ import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Note import com.orgzly.android.prefs.AppPreferences import com.orgzly.android.sync.SyncRunner -import com.orgzly.android.ui.* +import com.orgzly.android.ui.Breadcrumbs +import com.orgzly.android.ui.CommonFragment +import com.orgzly.android.ui.NotePlace +import com.orgzly.android.ui.NotePriorities +import com.orgzly.android.ui.NoteStates +import com.orgzly.android.ui.Place +import com.orgzly.android.ui.TimeType import com.orgzly.android.ui.dialogs.TimestampDialogFragment import com.orgzly.android.ui.drawer.DrawerItem import com.orgzly.android.ui.main.MainActivity @@ -36,7 +49,14 @@ import com.orgzly.android.ui.main.SharedMainActivityViewModel import com.orgzly.android.ui.notes.book.BookFragment import com.orgzly.android.ui.settings.SettingsActivity import com.orgzly.android.ui.share.ShareActivity -import com.orgzly.android.ui.util.* +import com.orgzly.android.ui.showSnackbar +import com.orgzly.android.ui.util.ActivityUtils +import com.orgzly.android.ui.util.KeyboardUtils +import com.orgzly.android.ui.util.getAlarmManager +import com.orgzly.android.ui.util.goneIf +import com.orgzly.android.ui.util.goneUnless +import com.orgzly.android.ui.util.invisibleIf +import com.orgzly.android.ui.util.invisibleUnless import com.orgzly.android.util.LogUtils import com.orgzly.android.util.OrgFormatter import com.orgzly.android.util.SpaceTokenizer @@ -45,7 +65,8 @@ import com.orgzly.databinding.FragmentNoteBinding import com.orgzly.org.OrgProperties import com.orgzly.org.datetime.OrgDateTime import com.orgzly.org.datetime.OrgRange -import java.util.* +import java.util.Collections +import java.util.TreeSet import javax.inject.Inject /** @@ -863,11 +884,13 @@ class NoteFragment : CommonFragment(), View.OnClickListener, TimestampDialogFrag when (id) { R.id.scheduled_button -> { updateTimestampView(TimeType.SCHEDULED, range) + ensureAlarmPermissions(time) viewModel.updatePayloadScheduledTime(range) } R.id.deadline_button -> { updateTimestampView(TimeType.DEADLINE, range) + ensureAlarmPermissions(time) viewModel.updatePayloadDeadlineTime(range) } @@ -878,6 +901,17 @@ class NoteFragment : CommonFragment(), View.OnClickListener, TimestampDialogFrag } } + private fun ensureAlarmPermissions(time: OrgDateTime?) { + if ((time != null) && time.hasTime()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!requireContext().getAlarmManager().canScheduleExactAlarms()) { + val uri = Uri.parse("package:" + BuildConfig.APPLICATION_ID) + activity?.startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM, uri)) + } + } + } + } + override fun onDateTimeAborted(id: Int, noteIds: TreeSet) { } From 58a1175700d92ac533b99c534abe8bbcf8a36132 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Apr 2024 01:06:12 +0200 Subject: [PATCH 02/11] Grant SCHEDULE_EXACT_ALARM permission during tests I removed the maxSdkVersion limit on the WRITE_EXTERNAL_STORAGE permission in the app manifest, because it is clearly needed on API 34. --- .../java/com/orgzly/android/OrgzlyTest.java | 9 ++++++++- .../orgzly/android/espresso/AgendaFragmentTest.java | 2 ++ .../orgzly/android/espresso/QueryFragmentTest.java | 2 ++ .../orgzly/android/espresso/util/EspressoUtils.java | 13 ++++++++++--- app/src/main/AndroidManifest.xml | 3 +-- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java b/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java index ad6d35738..dea5ce2e8 100644 --- a/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java +++ b/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java @@ -1,7 +1,10 @@ package com.orgzly.android; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + import android.Manifest; import android.app.Activity; +import android.app.UiAutomation; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; @@ -17,6 +20,7 @@ import com.orgzly.android.repos.RepoFactory; import com.orgzly.android.util.UserTimeFormatter; import com.orgzly.org.datetime.OrgDateTime; +import com.orgzly.test.BuildConfig; import org.junit.After; import org.junit.Before; @@ -56,9 +60,12 @@ public class OrgzlyTest { public GrantPermissionRule grantPermissionRule; public OrgzlyTest() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { this.grantPermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE); + } else { + getInstrumentation().getUiAutomation().grantRuntimePermission(App.getProcessName(), + Manifest.permission.WRITE_EXTERNAL_STORAGE); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { InstrumentationRegistry.getInstrumentation().getUiAutomation().grantRuntimePermission(App.getProcessName(), diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/AgendaFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/AgendaFragmentTest.java index 4f084dc9e..afa62cb76 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/AgendaFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/AgendaFragmentTest.java @@ -31,6 +31,7 @@ import com.orgzly.R; import com.orgzly.android.OrgzlyTest; +import com.orgzly.android.espresso.util.EspressoUtils; import com.orgzly.android.prefs.AppPreferences; import com.orgzly.android.ui.main.MainActivity; @@ -163,6 +164,7 @@ public void testRangeTaskMarkedDone() { @Test public void testMoveTaskWithRepeaterToTomorrow() { + EspressoUtils.grantAlarmsAndRemindersPermission(); DateTime tomorrow = DateTime.now().withTimeAtStartOfDay().plusDays(1); scenario = defaultSetUp(); searchForTextCloseKeyboard(".it.done ad.7"); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index 68033ec49..7472589ba 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -16,6 +16,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu; +import static com.orgzly.android.espresso.util.EspressoUtils.grantAlarmsAndRemindersPermission; import static com.orgzly.android.espresso.util.EspressoUtils.onActionItemClick; import static com.orgzly.android.espresso.util.EspressoUtils.onBook; import static com.orgzly.android.espresso.util.EspressoUtils.onNoteInBook; @@ -256,6 +257,7 @@ public void testClickingNote() { @Test public void testSchedulingNote() { defaultSetUp(); + grantAlarmsAndRemindersPermission(); onView(withId(R.id.drawer_layout)).perform(open()); onView(withText("Scheduled")).perform(click()); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index 51d24fd18..e6396407b 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -15,13 +15,14 @@ import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.anyOf; import static org.hamcrest.CoreMatchers.endsWith; import static org.hamcrest.Matchers.anything; import android.content.res.Resources; -import android.os.SystemClock; +import android.os.Build; import android.text.Spanned; import android.text.style.ClickableSpan; import android.view.KeyEvent; @@ -43,7 +44,6 @@ import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.espresso.util.HumanReadables; import androidx.test.espresso.util.TreeIterables; -import androidx.test.platform.app.InstrumentationRegistry; import com.orgzly.R; import com.orgzly.android.ui.SpanUtils; @@ -297,7 +297,7 @@ public static void onActionItemClick(int id, int resourceId) { // Open the overflow menu OR open the options menu, // depending on if the device has a hardware or software overflow menu button. - openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().getTargetContext()); + openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); onView(withText(resourceId)).perform(click()); } } @@ -501,4 +501,11 @@ public void perform(final UiController uiController, final View view) { } }; } + + public static void grantAlarmsAndRemindersPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + String shellCmd = "appops set --uid com.orgzlyrevived SCHEDULE_EXACT_ALARM allow"; + getInstrumentation().getUiAutomation().executeShellCommand(shellCmd); + } + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b084fa8b2..27112b767 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,8 +14,7 @@ - + From f292bcfcec1d9418739eaf89f9d75f4de354e4ea Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 24 Apr 2024 01:12:02 +0200 Subject: [PATCH 03/11] Work around mysterious button text not matching string The test works just fine on API 33, but on API 34 the string "4:05 AM" will never match, even though the actual textView looks exactly right. Strangely, matching any whitespace character instead of the space between "4:05" and "AM" works. I have no idea what is going on. And no other tests behave like this. --- .../com/orgzly/android/espresso/MiscTest.java | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java index 43d6dce90..263caff8c 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java @@ -32,6 +32,7 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertTrue; @@ -39,7 +40,6 @@ import android.app.Activity; import android.content.pm.ActivityInfo; import android.os.SystemClock; -import android.text.format.DateFormat; import android.view.View; import android.widget.DatePicker; import android.widget.TimePicker; @@ -49,26 +49,19 @@ import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.OrgzlyTest; -import com.orgzly.android.RetryTestRule; import com.orgzly.android.db.entity.NotePosition; +import com.orgzly.android.espresso.util.EspressoUtils; import com.orgzly.android.repos.RepoType; import com.orgzly.android.ui.main.MainActivity; import com.orgzly.android.ui.repos.ReposActivity; import org.hamcrest.Matcher; import org.junit.Assume; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TestRule; -import java.util.Calendar; -import java.util.GregorianCalendar; public class MiscTest extends OrgzlyTest { - @Rule - public TestRule mRetryTestRule = new RetryTestRule(); - @Test public void testLftRgt() { testUtils.setupBook("booky", "Preface\n* Note 1\n** Note 2\n* Note 3\n"); @@ -191,6 +184,7 @@ public void testSchedulingMultipleNotes() { "*** DONE Note #5.\n" + "CLOSED: [2014-06-03 Tue 13:34]\n" + ""); + EspressoUtils.grantAlarmsAndRemindersPermission(); try (ActivityScenario ignored = ActivityScenario.launch(MainActivity.class)) { onView(allOf(withText("book-name"), isDisplayed())).perform(click()); @@ -272,6 +266,7 @@ public void testBookTitleMustBeDisplayedWhenOpeningBookFromDrawer() { @Test public void testTimestampDialogTimeButtonValueWhenToggling() { + EspressoUtils.grantAlarmsAndRemindersPermission(); testUtils.setupBook("book-name", "Sample book used for tests\n" + "* TODO Note #1.\n" + "SCHEDULED: <2015-01-18 04:05 +6d>\n" + @@ -282,15 +277,14 @@ public void testTimestampDialogTimeButtonValueWhenToggling() { onNoteInBook(1).perform(click()); - Calendar cal = new GregorianCalendar(2015, 0, 18, 4, 5); - String s = DateFormat.getTimeFormat(context).format(cal.getTime()); + String regex = "4:05\\sAM"; onView(withId(R.id.scheduled_button)).perform(click()); - onView(withId(R.id.time_picker_button)).check(matches(withText(containsString(s)))); + onView(withId(R.id.time_picker_button)).check(matches(withText(matchesPattern(regex)))); onView(withId(R.id.time_used_checkbox)).perform(scroll(), click()); - onView(withId(R.id.time_picker_button)).check(matches(withText(containsString(s)))); + onView(withId(R.id.time_picker_button)).check(matches(withText(matchesPattern(regex)))); onView(withId(R.id.time_used_checkbox)).perform(click()); - onView(withId(R.id.time_picker_button)).check(matches(withText(containsString(s)))); + onView(withId(R.id.time_picker_button)).check(matches(withText(matchesPattern(regex)))); } } From dbdedbfcc0b8a8df2fa2456cf1a9884d26f6980c Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 5 May 2024 20:32:33 +0200 Subject: [PATCH 04/11] Run tests on API 29 and 34 I would like to run on 21 and 34, but I still need to iron out a few nasty test kinks on 21. --- .github/workflows/test.yaml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 62a4f23a5..1b7e76ebe 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,7 +18,10 @@ on: jobs: test: runs-on: ubuntu-latest - # TODO: Use strategy.matrix and always run on lowest and highest supported API + strategy: + matrix: + # TODO: Run on lowest and highest supported API + api-level: [29, 34] steps: - name: checkout uses: actions/checkout@v4 @@ -29,7 +32,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Restore/create Gradle cache + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - name: AVD cache @@ -39,14 +42,14 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-29 + key: avd-${{ matrix.api-level }} save-always: true - name: create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 29 + api-level: ${{ matrix.api-level }} arch: x86_64 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none @@ -62,7 +65,7 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-29 + key: avd-${{ matrix.api-level }} - name: Add Dropbox API credentials shell: bash @@ -73,10 +76,10 @@ jobs: - name: Run tests uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 29 + api-level: ${{ matrix.api-level }} arch: x86_64 force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -metrics-collection disable-animations: true disable-spellchecker: true profile: Nexus 6 From 4db28994dcc51f5b17d999bb03fa4beb8d1dc787 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 6 May 2024 00:22:32 +0200 Subject: [PATCH 05/11] searchForTextCloseKeyboard is flaky --- .../java/com/orgzly/android/espresso/util/EspressoUtils.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index e6396407b..5dddd0038 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -337,7 +337,9 @@ public static ViewInteraction contextualToolbarOverflowMenu() { } public static void searchForTextCloseKeyboard(String str) { + onView(isRoot()).perform(waitId(R.id.search_view, 5000)); onView(allOf(withId(R.id.search_view), isDisplayed())).perform(click()); + onView(isRoot()).perform(waitId(R.id.search_src_text, 5000)); onView(withId(R.id.search_src_text)).perform(replaceText(str), pressKey(KeyEvent.KEYCODE_ENTER)); closeSoftKeyboardWithDelay(); } From 7095bb66c464e7b1629a76bd1d00f13b51178617 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 6 May 2024 08:42:38 +0200 Subject: [PATCH 06/11] Sleep after rotating screen --- .../androidTest/java/com/orgzly/android/espresso/MiscTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java index 263caff8c..92539d2d8 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java @@ -240,6 +240,7 @@ public void testNewBookDialogShouldSurviveScreenRotation() { scenario.onActivity(activity -> activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)); + SystemClock.sleep(1000); onView(withId(R.id.dialog_new_book_container)).check(matches(isDisplayed())); onView(withId(R.id.dialog_input)).perform(replaceTextCloseKeyboard("notebook")); onView(withText(R.string.create)).perform(click()); From aafe57e6015b7266f8a9ee7b290e26db5fd98aca Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 6 May 2024 15:32:05 +0200 Subject: [PATCH 07/11] Use replaceTextCloseKeyboard and add a couple of sleeps in SavedSearchesFragmentTest --- .../orgzly/android/espresso/SavedSearchesFragmentTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SavedSearchesFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SavedSearchesFragmentTest.java index 709c4b94d..8b32d8275 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SavedSearchesFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SavedSearchesFragmentTest.java @@ -14,11 +14,13 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu; import static com.orgzly.android.espresso.util.EspressoUtils.onActionItemClick; import static com.orgzly.android.espresso.util.EspressoUtils.onSavedSearch; import static com.orgzly.android.espresso.util.EspressoUtils.onSnackbar; import static com.orgzly.android.espresso.util.EspressoUtils.replaceTextCloseKeyboard; +import static com.orgzly.android.espresso.util.EspressoUtils.waitId; import static org.hamcrest.Matchers.allOf; import android.app.Activity; @@ -75,9 +77,8 @@ public void testNewSameNameSavedSearch() { public void testUpdateSameNameSavedSearch() { onView(withId(R.id.fragment_saved_searches_flipper)).check(matches(isDisplayed())); onSavedSearch(0).perform(click()); - SystemClock.sleep(500); onView(withId(R.id.fragment_saved_search_flipper)).check(matches(isDisplayed())); - onView(withId(R.id.fragment_saved_search_query)).perform(typeText(" edited")); + onView(withId(R.id.fragment_saved_search_query)).perform(replaceTextCloseKeyboard(" edited")); onView(withId(R.id.done)).perform(click()); // Saved search done onView(withId(R.id.fragment_saved_searches_flipper)).check(matches(isDisplayed())); } From 750e8a242e0d2cd1aaa1d106284290b869641a38 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 6 May 2024 08:50:21 +0200 Subject: [PATCH 08/11] Apply retry rule in some more test classes Because they are simply flaky on API 34. --- .../android/espresso/CreatedAtPropertyTest.java | 5 +++++ .../java/com/orgzly/android/espresso/MiscTest.java | 5 +++++ .../java/com/orgzly/android/espresso/SyncingTest.java | 11 ++++++++--- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java index cfd2693ae..93d88d82c 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java @@ -6,11 +6,13 @@ import com.orgzly.R; import com.orgzly.android.OrgzlyTest; +import com.orgzly.android.RetryTestRule; import com.orgzly.android.ui.main.MainActivity; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.junit.Rule; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.Espresso.pressBack; @@ -34,6 +36,9 @@ public class CreatedAtPropertyTest extends OrgzlyTest { private ActivityScenario scenario; + @Rule + public RetryTestRule mRetryTestRule = new RetryTestRule(); + @Before public void setUp() throws Exception { super.setUp(); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java index 92539d2d8..f3ab040de 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java @@ -49,6 +49,7 @@ import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.OrgzlyTest; +import com.orgzly.android.RetryTestRule; import com.orgzly.android.db.entity.NotePosition; import com.orgzly.android.espresso.util.EspressoUtils; import com.orgzly.android.repos.RepoType; @@ -57,11 +58,15 @@ import org.hamcrest.Matcher; import org.junit.Assume; +import org.junit.Rule; import org.junit.Test; public class MiscTest extends OrgzlyTest { + @Rule + public RetryTestRule mRetryTestRule = new RetryTestRule(); + @Test public void testLftRgt() { testUtils.setupBook("booky", "Preface\n* Note 1\n** Note 2\n* Note 3\n"); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index f42331625..4acb0d06e 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -38,6 +38,7 @@ import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.OrgzlyTest; +import com.orgzly.android.RetryTestRule; import com.orgzly.android.db.entity.Repo; import com.orgzly.android.repos.RepoType; import com.orgzly.android.sync.BookSyncStatus; @@ -47,17 +48,18 @@ import org.junit.After; import org.junit.Assert; import org.junit.Assume; +import org.junit.Rule; import org.junit.Test; import java.io.IOException; @SuppressWarnings("unchecked") public class SyncingTest extends OrgzlyTest { - /** - * Utility method for starting sync using drawer button. - */ private ActivityScenario scenario; + @Rule + public RetryTestRule mRetryTestRule = new RetryTestRule(); + @After @Override public void tearDown() throws Exception { @@ -67,6 +69,9 @@ public void tearDown() throws Exception { } } + /** + * Utility method for starting sync using drawer button. + */ private void sync() { onView(withId(R.id.drawer_layout)).perform(open()); onView(withId(R.id.sync_button_container)).perform(click()); From ae1f4c494c874335577ef5a50727bc67b1ecb389 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 6 May 2024 22:39:40 +0200 Subject: [PATCH 09/11] Don't retry skipped tests --- app/src/androidTest/java/com/orgzly/android/RetryTestRule.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/androidTest/java/com/orgzly/android/RetryTestRule.kt b/app/src/androidTest/java/com/orgzly/android/RetryTestRule.kt index 999eeb754..a20b93683 100644 --- a/app/src/androidTest/java/com/orgzly/android/RetryTestRule.kt +++ b/app/src/androidTest/java/com/orgzly/android/RetryTestRule.kt @@ -1,6 +1,7 @@ package com.orgzly.android import android.util.Log +import org.junit.AssumptionViolatedException import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement @@ -29,6 +30,9 @@ class RetryTestRule(val retryCount: Int = 3) : TestRule { return } catch (t: Throwable) { caughtThrowable = t + if (caughtThrowable is AssumptionViolatedException) { + throw caughtThrowable + } Log.e(TAG, description.displayName + ": run " + (i + 1) + " failed") } } From 57a4d4803c835ff35dde61d416eceefb3b7b72a7 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 6 May 2024 22:42:21 +0200 Subject: [PATCH 10/11] Sleep in a few more places to allow reaching the right root view before waiting for the right view ID. --- .../java/com/orgzly/android/espresso/QueryFragmentTest.java | 1 + .../java/com/orgzly/android/espresso/util/EspressoUtils.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index 7472589ba..ccb0f7e98 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -268,6 +268,7 @@ public void testSchedulingNote() { onView(withId(R.id.date_picker_button)).perform(click()); onView(withClassName(equalTo(DatePicker.class.getName()))).perform(setDate(2014, 4, 1)); onView(withText(android.R.string.ok)).perform(click()); + SystemClock.sleep(500); onView(isRoot()).perform(waitId(R.id.time_picker_button, 5000)); onView(withId(R.id.time_picker_button)).perform(scroll(), click()); onView(withClassName(equalTo(TimePicker.class.getName()))).perform(setTime(9, 15)); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index 5dddd0038..66141e453 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -23,6 +23,7 @@ import android.content.res.Resources; import android.os.Build; +import android.os.SystemClock; import android.text.Spanned; import android.text.style.ClickableSpan; import android.view.KeyEvent; @@ -187,6 +188,7 @@ public static ViewInteraction onSavedSearch(int position) { } public static ViewInteraction onRecyclerViewItem(@IdRes int recyclerView, int position, @IdRes int childView) { + SystemClock.sleep(100); onView(isRoot()).perform(waitId(recyclerView, 5000)); onView(withId(recyclerView)).perform(RecyclerViewActions.scrollToPosition(position)); return onView(new EspressoRecyclerViewMatcher(recyclerView) From d19a353eeda1db617acc2faab2372c47f0cfc3ad Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 9 May 2024 08:38:40 +0200 Subject: [PATCH 11/11] Another short sleep before identifying root view --- .../java/com/orgzly/android/espresso/util/EspressoUtils.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index 66141e453..d2e8aa1f3 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -339,6 +339,7 @@ public static ViewInteraction contextualToolbarOverflowMenu() { } public static void searchForTextCloseKeyboard(String str) { + SystemClock.sleep(100); onView(isRoot()).perform(waitId(R.id.search_view, 5000)); onView(allOf(withId(R.id.search_view), isDisplayed())).perform(click()); onView(isRoot()).perform(waitId(R.id.search_src_text, 5000));