Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
13bedbe
Move to Material 3 with real night resources (#524)
andiwand Jul 28, 2026
4703ce3
Replace the landing copy with the recently opened documents (#525)
andiwand Jul 28, 2026
32ff76d
Open the system picker directly (#526)
andiwand Jul 28, 2026
81feabb
Browse the folders you have granted access to (#527)
andiwand Jul 28, 2026
6892a51
Keep the settings on screen while there is nothing to list
andiwand Jul 28, 2026
d548d92
Swipe a recent document away, and say when it was opened (#528)
andiwand Jul 28, 2026
039316f
Drop the open action from the toolbar (#529)
andiwand Jul 28, 2026
b5f0e2e
Let the row say it is a recent, instead of guessing from the subtitle
andiwand Jul 28, 2026
5e82e34
Cap the recents list when an undo puts a document back
andiwand Jul 28, 2026
9319f87
Offer the ad removal from the settings section (#530)
andiwand Jul 28, 2026
ff69c3c
Put the document actions where the thumb is (#531)
andiwand Jul 28, 2026
6260ce5
Take the empty toolbar off the screen (#542)
andiwand Jul 28, 2026
c5cc24f
Ask the core what can be edited, in the document UI too
andiwand Jul 29, 2026
c21e1c3
Close the three gaps the review found
andiwand Jul 28, 2026
464e65d
Take out what the redesign left behind
andiwand Jul 28, 2026
3636e6c
Say which folder you are in, and remove a recent one way
andiwand Jul 28, 2026
a80f9ee
Scroll to the setting the test is looking for
andiwand Jul 28, 2026
b3f87a0
Shorten CLAUDE.md to the things it is needed for
andiwand Jul 28, 2026
e6a54fc
Close the keyboard before clicking what it is covering
andiwand Jul 28, 2026
d7fa88c
Swipe a folder away too, and stop asking first
andiwand Jul 28, 2026
ba2314a
Delete the strings whose features are gone
andiwand Jul 28, 2026
dae7aea
Keep the open button on screen while the list is empty
andiwand Jul 28, 2026
575c23a
Lift the document buttons off the bottom of the screen
andiwand Jul 28, 2026
545085b
Apply suggestions from code review
andiwand Jul 30, 2026
38687db
Wrap the comment spotless was tripping over
andiwand Jul 30, 2026
faf729f
Merge remote-tracking branch 'origin/main' into redesign
andiwand Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 134 additions & 183 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,19 @@ dependencies {
implementation libs.androidx.core
implementation libs.material
implementation libs.androidx.webkit
implementation libs.androidx.recyclerview
implementation libs.androidx.lifecycle.viewmodel
implementation libs.androidx.lifecycle.livedata

implementation libs.asset.extractor

testImplementation libs.junit

androidTestImplementation libs.espresso.core
androidTestImplementation libs.espresso.intents
// RecyclerViewActions: the landing screen is one list, and what a test wants to reach
// is regularly a row that has not been bound yet on a small screen
androidTestImplementation libs.espresso.contrib
androidTestImplementation libs.androidx.test.rules
androidTestImplementation libs.androidx.test.runner
androidTestImplementation libs.androidx.test.junit
Expand Down
336 changes: 336 additions & 0 deletions app/src/androidTest/java/app/opendocument/droid/test/LandingTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
// ActivityTestRule instead of ActivityScenario, matching MainActivityTests - see the note there.
@file:Suppress("DEPRECATION")

package app.opendocument.droid.test

import android.app.Activity
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import androidx.core.content.FileProvider
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import app.opendocument.droid.R
import app.opendocument.droid.background.FolderTreesUtil
import app.opendocument.droid.background.RecentDocumentsUtil
import app.opendocument.droid.ui.activity.DocumentFragment
import app.opendocument.droid.ui.activity.MainActivity
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/** The landing screen: the recently opened documents and the actions offered next to them. */
@LargeTest
@RunWith(AndroidJUnit4::class)
class LandingTests {

// launched by hand in each test, so that the recently opened list can be seeded first -
// the landing screen reads it while it is coming up
@get:Rule val activityRule = ActivityTestRule(MainActivity::class.java, false, false)

@Before
fun setUp() {
clearLandingState()

Intents.init()
}

@After
fun tearDown() {
Intents.release()

clearLandingState()

if (activityRule.activity != null) {
activityRule.finishActivity()

InstrumentationRegistry.getInstrumentation().waitForIdleSync()
}
}

@Test
fun emptyStateIsShownWhenNothingWasOpenedYet() {
launch()

onView(withId(R.id.landing_empty)).check(matches(isDisplayed()))
}

@Test
fun emptyStateOpensTheSystemPicker() {
stubOpenDocumentCancelled()

launch()

onView(withId(R.id.landing_empty_open)).perform(closeSoftKeyboard(), click())

Intents.intended(hasAction(Intent.ACTION_OPEN_DOCUMENT))
}

@Test
fun aRecentDocumentIsListed() {
seedRecentDocument()

launch()

onView(withText(TEST_DOCUMENT)).check(matches(isDisplayed()))
}

@Test
fun aRecentDocumentOpensWhenTapped() {
seedRecentDocument()

val activity = launch()

onView(withText(TEST_DOCUMENT)).perform(closeSoftKeyboard(), click())

Assert.assertTrue(
"the document did not load after tapping its entry in the recent list",
waitForDocumentFragment(activity),
)
}

@Test
fun theFabOpensTheSystemPicker() {
// deliberately not seeded: the fab is there whatever the list holds, empty state included
stubOpenDocumentCancelled()

launch()

onView(withId(R.id.landing_open_fab)).perform(closeSoftKeyboard(), click())

Intents.intended(hasAction(Intent.ACTION_OPEN_DOCUMENT))
}

@Test
fun addingAFolderAsksTheSystemForATree() {
Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT_TREE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))

launch()

onView(withId(R.id.landing_empty_add_folder)).perform(closeSoftKeyboard(), click())

Intents.intended(hasAction(Intent.ACTION_OPEN_DOCUMENT_TREE))
}

@Test
fun aGrantedFolderIsListed() {
seedFolderTree()

launch()

onView(withText(TEST_FOLDER)).check(matches(isDisplayed()))
}

/**
* Removing a granted folder, which a long press and a swipe now reach the same way - the swipe
* gesture itself is ItemTouchHelper's, so this covers what both of them call.
*/
@Test
fun aGrantedFolderCanBeRemoved() {
seedFolderTree()

launch()

onView(withText(TEST_FOLDER)).perform(closeSoftKeyboard(), longClick())

onView(withText(R.string.landing_folder_removed)).check(matches(isDisplayed()))
onView(withText(TEST_FOLDER)).check(doesNotExist())
}

/** The undo next to it puts the folder back, cached name and all. */
@Test
fun removingAGrantedFolderCanBeUndone() {
seedFolderTree()

launch()

onView(withText(TEST_FOLDER)).perform(closeSoftKeyboard(), longClick())
onView(withText(R.string.landing_undo)).perform(click())

InstrumentationRegistry.getInstrumentation().waitForIdleSync()
SystemClock.sleep(500)

onView(withText(TEST_FOLDER)).check(matches(isDisplayed()))
}

@Test
fun theFoldersSectionOffersToAddOne() {
seedRecentDocument()

launch()

onView(withText(R.string.landing_section_folders)).check(matches(isDisplayed()))

// the empty state carries a button with the same label, so match the one on screen -
// the empty state is gone once there is a recent document to show
onView(allOf(withText(R.string.landing_add_folder), isDisplayed()))
.check(matches(isDisplayed()))
}

@Test
fun theCatchAllSettingIsOffered() {
seedRecentDocument()

launch()

scrollToCatchAllSetting()

onView(withText(R.string.landing_catch_all_title)).check(matches(isDisplayed()))
}

@Test
fun theCatchAllSettingIsOfferedBeforeAnythingWasOpened() {
// a fresh install is where the switch matters most, and the empty state used to be shown
// instead of the list it sits in
launch()

scrollToCatchAllSetting()

onView(withText(R.string.landing_catch_all_title)).check(matches(isDisplayed()))
}

/**
* The settings sit under whatever the list is showing, and the empty state alone is taller than
* a small screen - so on the emulators CI runs, the switch is not merely off screen but never
* bound at all, and no matcher can find it.
*
* Scrolling is the whole point of the row being in the list rather than a screen of its own, so
* this scrolls the way a user would and then asserts. It has to say nothing about how far.
*/
private fun scrollToCatchAllSetting() {
onView(withId(R.id.landing_list))
.perform(
RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(
hasDescendant(withText(R.string.landing_catch_all_title))
)
)
}

private fun launch(): MainActivity {
val activity = activityRule.launchActivity(null)

activity.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))

// the list is filled from a background executor, so give it a moment to arrive
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
SystemClock.sleep(1000)
InstrumentationRegistry.getInstrumentation().waitForIdleSync()

return activity
}

/**
* The picker is stubbed as cancelled: these tests are about what the landing screen asks for,
* not about loading a document.
*/
private fun stubOpenDocumentCancelled() {
Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))
}

private fun seedRecentDocument() {
RecentDocumentsUtil.addRecentDocument(context(), TEST_DOCUMENT, uriOf(requireTestFile()))
}

/**
* A folder tree written straight into the store, cached display name and all.
*
* No real grant behind it, which is enough for the row: the name is the one written down when
* the folder was added, so listing it asks no provider anything. Entering it would come up
* empty, and none of these tests do.
*/
private fun seedFolderTree() {
FolderTreesUtil.addFolderTree(
context(),
Uri.parse("content://app.opendocument.test/tree/seeded"),
TEST_FOLDER,
)
}

/**
* Both stores, not just the recent documents: a folder granted on this device - by a previous
* run, or by hand - would keep the empty state from ever being shown.
*/
private fun clearLandingState() {
context().deleteFile("recent_documents.json")
context().deleteFile("folder_trees.json")
}

private fun waitForDocumentFragment(activity: MainActivity): Boolean {
val deadline = SystemClock.uptimeMillis() + LOAD_TIMEOUT_MS

while (SystemClock.uptimeMillis() < deadline) {
val fragment =
activity.supportFragmentManager.findFragmentByTag("document_fragment")
as DocumentFragment?
if (fragment != null) {
return true
}

SystemClock.sleep(200)
}

return false
}

private fun context(): Context = InstrumentationRegistry.getInstrumentation().targetContext

private fun uriOf(file: File): Uri =
FileProvider.getUriForFile(context(), context().packageName + ".provider", file)

private fun requireTestFile(): File = checkNotNull(testFile) { "test file was not extracted" }

companion object {
private const val TEST_DOCUMENT = "test.odt"
private const val TEST_FOLDER = "Seeded folder"
private const val LOAD_TIMEOUT_MS = 20000L

private var testFile: File? = null

// @JvmStatic because junit requires @BeforeClass to be static
@JvmStatic
@BeforeClass
fun extractTestFile() {
val instrumentation = InstrumentationRegistry.getInstrumentation()

val directory = File(instrumentation.targetContext.cacheDir, "test-documents")
directory.mkdirs()

val target = File(directory, TEST_DOCUMENT)
copy(instrumentation.context.assets.open(TEST_DOCUMENT), target)

testFile = target
}

private fun copy(source: InputStream, target: File) {
source.use { input -> FileOutputStream(target).use { output -> input.copyTo(output) } }
}
}
}
Loading
Loading