diff --git a/app/build.gradle b/app/build.gradle index d9535cb81ff7..591ad817616a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -244,6 +244,9 @@ 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 diff --git a/app/src/androidTest/java/app/opendocument/droid/test/LandingTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/LandingTests.kt new file mode 100644 index 000000000000..e526011dd7da --- /dev/null +++ b/app/src/androidTest/java/app/opendocument/droid/test/LandingTests.kt @@ -0,0 +1,175 @@ +// ActivityTestRule instead of ActivityScenario, matching MainActivityTests - see the note there. +@file:Suppress("DEPRECATION") + +package app.opendocument.droid.test + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.SystemClock +import androidx.core.content.FileProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +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.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.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 aRecentDocumentIsListed() { + seedRecentDocument() + + launch() + + onView(withText(TEST_DOCUMENT)).check(matches(isDisplayed())) + } + + @Test + fun aRecentDocumentOpensWhenTapped() { + seedRecentDocument() + + val activity = launch() + + onView(withText(TEST_DOCUMENT)).perform(click()) + + Assert.assertTrue( + "the document did not load after tapping its entry in the recent list", + waitForDocumentFragment(activity), + ) + } + + @Test + fun theCatchAllSettingIsOffered() { + seedRecentDocument() + + launch() + + onView(withText(R.string.landing_catch_all_title)).check(matches(isDisplayed())) + } + + 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 + } + + private fun seedRecentDocument() { + RecentDocumentsUtil.addRecentDocument(context(), TEST_DOCUMENT, uriOf(requireTestFile())) + } + + private fun clearLandingState() { + context().deleteFile("recent_documents.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 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) } } + } + } +} diff --git a/app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt b/app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt new file mode 100644 index 000000000000..d366d02b443f --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt @@ -0,0 +1,80 @@ +package app.opendocument.droid.background + +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager + +/** + * Whether the app offers itself for every file type, or only for the document types it actually + * supports. + * + * Backed by the two activity aliases in the manifest: exactly one of them is enabled at a time, and + * which one decides how broad the intent filter the system sees is. + */ +object CatchAllSetting { + + private const val PREF_CATCH_ALL_ENABLED = "catch_all_enabled" + + // these keep the historical at.tomtasche.reader names on purpose: the component name is + // what the OS persists when a user picks "always open .odt with this app", and what + // setComponentEnabledSetting stores the toggle against. renaming them would drop those + // defaults for every existing install, so the manifest declares the aliases under the old + // names too. + private const val CATCH_ALL_COMPONENT = "at.tomtasche.reader.ui.activity.MainActivity.CATCH_ALL" + private const val STRICT_CATCH_COMPONENT = + "at.tomtasche.reader.ui.activity.MainActivity.STRICT_CATCH" + + /** + * Puts the aliases back in the state the stored setting asks for. + * + * Has to run on every launch, not just when the landing screen is shown: users upgrading from a + * version that shipped different alias defaults are only corrected here, and the app is + * regularly started straight into a document by an external intent. + */ + fun applyOnLaunch(context: Context): Boolean { + val enabled = isEnabled(context) + + apply(context, enabled) + + return enabled + } + + fun isEnabled(context: Context): Boolean = + // catch-all is only active if the user explicitly opted in. new installs and existing + // users who never touched the switch default to STRICT_CATCH, so we no longer volunteer + // to open unrelated file types like contacts (issue #477). + preferences(context).getBoolean(PREF_CATCH_ALL_ENABLED, false) + + fun setEnabled(context: Context, enabled: Boolean) { + preferences(context).edit().putBoolean(PREF_CATCH_ALL_ENABLED, enabled).apply() + + apply(context, enabled) + } + + private fun apply(context: Context, enabled: Boolean) { + toggleComponent(context, CATCH_ALL_COMPONENT, enabled) + toggleComponent(context, STRICT_CATCH_COMPONENT, !enabled) + } + + private fun toggleComponent(context: Context, className: String, enabled: Boolean) { + val newState = + if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + + context.packageManager.setComponentEnabledSetting( + ComponentName(context, className), + newState, + PackageManager.DONT_KILL_APP, + ) + } + + /** + * The preferences android.preference.PreferenceManager used to hand out. That class is + * deprecated and its androidx replacement lives in a whole preference-ui library we do not + * otherwise need, so the default file is opened by name instead - keeping the existing + * catch-all setting of users who upgrade. + */ + private fun preferences(context: Context): SharedPreferences = + context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/LandingFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/LandingFragment.kt new file mode 100644 index 000000000000..7d8d884fe5a2 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/activity/LandingFragment.kt @@ -0,0 +1,161 @@ +package app.opendocument.droid.ui.activity + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import app.opendocument.droid.R +import app.opendocument.droid.nonfree.AnalyticsConstants +import app.opendocument.droid.ui.widget.LandingAdapter +import app.opendocument.droid.ui.widget.LandingItem +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.floatingactionbutton.FloatingActionButton + +/** + * The screen the app opens on: the documents the user was last working on. + * + * A fragment rather than more views inside MainActivity, following DocumentFragment - the list + * state belongs in a ViewModel so it survives the recreation a theme or rotation change causes. + */ +class LandingFragment : Fragment(), LandingAdapter.Listener { + + // ViewModelProvider rather than the by viewModels() delegate, which lives in fragment-ktx - + // this project sticks to the non-ktx androidx artifacts. same as DocumentFragment. + private lateinit var viewModel: LandingViewModel + + private lateinit var adapter: LandingAdapter + private lateinit var list: RecyclerView + private lateinit var empty: View + private lateinit var fab: FloatingActionButton + + private var landingVisible = true + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = inflater.inflate(R.layout.fragment_landing, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(this)[LandingViewModel::class.java] + + adapter = LandingAdapter(this) + + list = view.findViewById(R.id.landing_list) + list.layoutManager = LinearLayoutManager(requireContext()) + list.adapter = adapter + + empty = view.findViewById(R.id.landing_empty) + + fab = view.findViewById(R.id.landing_open_fab) + fab.setOnClickListener { + mainActivity.analyticsManager.report("fab_open") + + mainActivity.findDocument() + } + view.findViewById(R.id.landing_empty_open).setOnClickListener { + mainActivity.analyticsManager.report("empty_open") + + mainActivity.findDocument() + } + + viewModel.state.observe(viewLifecycleOwner) { state -> render(state) } + } + + override fun onResume() { + super.onResume() + + // a document may have been opened from another app while we were in the background + viewModel.refresh() + } + + /** + * Called by MainActivity when it swaps between the landing screen and a document. + * + * The fragment is only hidden, never stopped - so nothing in the lifecycle fires when a + * document is closed, and the list would keep showing what it held before the document was + * opened, missing the document that was just added to it. + */ + fun setLandingVisible(visible: Boolean) { + landingVisible = visible + + // MainActivity can swap the containers before the view exists, on a launch that goes + // straight into a document; onViewCreated refreshes anyway once it gets there + if (!::viewModel.isInitialized) { + return + } + + if (visible) { + viewModel.refresh() + } + } + + private fun render(state: LandingViewModel.State) { + val items = ArrayList() + + if (state.documents.isNotEmpty()) { + items.add(LandingItem.Header(R.string.landing_section_recent)) + for (document in state.documents) { + items.add(LandingItem.Document(document.filename, Uri.parse(document.uri))) + } + } + + items.add(LandingItem.Header(R.string.landing_section_settings)) + items.add(LandingItem.CatchAll(state.catchAllEnabled)) + + // with nothing to show but the setting, the list is just noise around one button + val isEmpty = state.documents.isEmpty() + + empty.visibility = if (isEmpty) View.VISIBLE else View.GONE + list.visibility = if (isEmpty) View.GONE else View.VISIBLE + + // the empty state offers the same thing with a label on it, so the bare fab would just + // be a second unexplained button next to it + fab.visibility = if (isEmpty) View.GONE else View.VISIBLE + + adapter.submitList(items) + } + + override fun onDocumentClicked(document: LandingItem.Document) { + mainActivity.analyticsManager.report( + AnalyticsConstants.EVENT_SELECT_CONTENT, + AnalyticsConstants.PARAM_CONTENT_TYPE, + "recent", + ) + + mainActivity.loadUri(document.uri) + } + + override fun onDocumentRemoveRequested(document: LandingItem.Document) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(document.filename) + .setMessage(R.string.landing_remove_recent_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.landing_remove_recent) { _, _ -> + viewModel.removeRecentDocument(document.uri) + } + .show() + } + + override fun onCatchAllChanged(enabled: Boolean) { + viewModel.setCatchAllEnabled(enabled) + + mainActivity.analyticsManager.report( + if (enabled) "catch_all_enabled" else "catch_all_disabled" + ) + } + + private val mainActivity: MainActivity + get() = requireActivity() as MainActivity + + companion object { + const val FRAGMENT_TAG: String = "landing_fragment" + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/LandingViewModel.kt b/app/src/main/java/app/opendocument/droid/ui/activity/LandingViewModel.kt new file mode 100644 index 000000000000..ee42f45a46a6 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/activity/LandingViewModel.kt @@ -0,0 +1,97 @@ +package app.opendocument.droid.ui.activity + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import app.opendocument.droid.background.CatchAllSetting +import app.opendocument.droid.background.PersistedUriPermissions +import app.opendocument.droid.background.RecentDocumentList +import app.opendocument.droid.background.RecentDocumentsUtil +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * What the landing screen shows. + * + * Reading the recently opened documents touches a file and checking whether they still resolve + * talks to their content provider, so both happen on [executor] and are published through + * [LiveData] - which, unlike posting to a handler, drops the result when the fragment is gone. + * + * There are no coroutines anywhere in this project, so this follows the executor plus handler shape + * the loaders already use. + */ +class LandingViewModel(application: Application) : AndroidViewModel(application) { + + class State(val documents: List, val catchAllEnabled: Boolean) + + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + + private val mutableState = MutableLiveData() + val state: LiveData = mutableState + + /** + * Publishes what is on disk right away, then re-publishes once the documents that no longer + * resolve have been dropped. Rendering does not wait on the provider round trip that way. + */ + fun refresh() { + executor.execute { + val context = getApplication() + + val stored = RecentDocumentsUtil.getRecentDocuments(context) + val catchAll = CatchAllSetting.isEnabled(context) + + mutableState.postValue(State(stored, catchAll)) + + val alive = stored.filter { isReadable(context, Uri.parse(it.uri)) } + if (alive.size == stored.size) { + return@execute + } + + for (entry in stored - alive.toSet()) { + RecentDocumentsUtil.removeRecentDocument(context, Uri.parse(entry.uri)) + } + PersistedUriPermissions.prune(context) + + mutableState.postValue(State(alive, catchAll)) + } + } + + fun setCatchAllEnabled(enabled: Boolean) { + CatchAllSetting.setEnabled(getApplication(), enabled) + + refresh() + } + + fun removeRecentDocument(uri: Uri) { + executor.execute { + RecentDocumentsUtil.removeRecentDocument(getApplication(), uri) + PersistedUriPermissions.prune(getApplication()) + + refresh() + } + } + + override fun onCleared() { + super.onCleared() + + executor.shutdown() + } + + /** + * Whether the document behind [uri] can still be reached. A grant revoked while the app was + * away, a removed sd card and a deleted file all end up here. + */ + private fun isReadable(context: Context, uri: Uri): Boolean { + return try { + context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + .use { cursor -> cursor != null && cursor.moveToFirst() } + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index 6e11765a9714..0b3ec7a8f3bf 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -3,11 +3,8 @@ package app.opendocument.droid.ui.activity import android.app.Activity import android.content.ActivityNotFoundException import android.content.ComponentName -import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences -import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.content.res.Configuration import android.net.Uri @@ -25,14 +22,13 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.SwitchCompat import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.fragment.app.DialogFragment import app.opendocument.droid.R +import app.opendocument.droid.background.CatchAllSetting import app.opendocument.droid.background.LoaderService import app.opendocument.droid.background.LoaderServiceQueue import app.opendocument.droid.background.PersistedUriPermissions @@ -47,7 +43,6 @@ import app.opendocument.droid.ui.FindActionModeCallback import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.SnackbarHelper import app.opendocument.droid.ui.TtsActionModeCallback -import app.opendocument.droid.ui.widget.RecentDocumentDialogFragment import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -60,6 +55,11 @@ class MainActivity : AppCompatActivity(), MenuProvider { private lateinit var adContainer: LinearLayout private var documentFragment: DocumentFragment? = null + private val landingFragment: LandingFragment? + get() = + supportFragmentManager.findFragmentByTag(LandingFragment.FRAGMENT_TAG) + as LandingFragment? + private var fullscreen = false // With targetSdk 36 predictive back is enabled by default and neither KEYCODE_BACK @@ -192,19 +192,21 @@ class MainActivity : AppCompatActivity(), MenuProvider { landingContainer = findViewById(R.id.landing_container) documentContainer = findViewById(R.id.document_container) - findViewById(R.id.landing_intro_open).setOnClickListener { - analyticsManager.report("intro_open") - findDocument() - } - findViewById(R.id.landing_open_fab).setOnClickListener { - analyticsManager.report("fab_open") - findDocument() + if (supportFragmentManager.findFragmentByTag(LandingFragment.FRAGMENT_TAG) == null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.landing_container, LandingFragment(), LandingFragment.FRAGMENT_TAG) + .commitNow() } printingManager = PrintingManager() initializeProprietaryLibraries() - initializeCatchAllSwitch() + // has to happen here rather than in LandingFragment: users upgrading from a version + // with different alias defaults have to be corrected even when the app is launched + // straight into a document and the landing screen is never shown + val catchAllEnabled = CatchAllSetting.applyOnLaunch(this) + analyticsManager.report(if (catchAllEnabled) "catch_all_enabled" else "catch_all_disabled") // reclaims the uri permissions of documents that have since dropped off the recently // opened list. touches the filesystem and the permission binder, so not on the main thread @@ -281,60 +283,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { this.loadOnStart = null } - private fun initializeCatchAllSwitch() { - // these keep the historical at.tomtasche.reader names on purpose: the component - // name is what the OS persists when a user picks "always open .odt with this - // app", and what setComponentEnabledSetting below stores the toggle against. - // renaming them would drop those defaults for every existing install, so the - // manifest declares the aliases under the old names too. - val catchAllComponent = - ComponentName(this, "at.tomtasche.reader.ui.activity.MainActivity.CATCH_ALL") - val strictCatchComponent = - ComponentName(this, "at.tomtasche.reader.ui.activity.MainActivity.STRICT_CATCH") - - val preferences = getDefaultSharedPreferences() - - // catch-all is only active if the user explicitly opted in. new installs and - // existing users who never touched the switch default to STRICT_CATCH, so we - // no longer volunteer to open unrelated file types like contacts (issue #477). - val isCatchAllEnabled = preferences.getBoolean(PREF_CATCH_ALL_ENABLED, false) - - // retoggle components for users upgrading to latest version of app - toggleComponent(catchAllComponent, isCatchAllEnabled) - toggleComponent(strictCatchComponent, !isCatchAllEnabled) - - val catchAllSwitch = findViewById(R.id.landing_catch_all) - - catchAllSwitch.setOnCheckedChangeListener { _, isChecked -> - preferences.edit().putBoolean(PREF_CATCH_ALL_ENABLED, isChecked).apply() - - toggleComponent(catchAllComponent, isChecked) - toggleComponent(strictCatchComponent, !isChecked) - } - - catchAllSwitch.isChecked = isCatchAllEnabled - - analyticsManager.report( - if (isCatchAllEnabled) "catch_all_enabled" else "catch_all_disabled" - ) - } - - /** - * The preferences android.preference.PreferenceManager used to hand out. That class is - * deprecated and its androidx replacement lives in a whole preference-ui library we do not - * otherwise need, so the default file is opened by name instead - keeping the existing - * catch-all setting of users who upgrade. - */ - private fun getDefaultSharedPreferences(): SharedPreferences = - getSharedPreferences(packageName + "_preferences", Context.MODE_PRIVATE) - - private fun toggleComponent(component: ComponentName, enabled: Boolean) { - val newState = - if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED - else PackageManager.COMPONENT_ENABLED_STATE_DISABLED - packageManager.setComponentEnabledSetting(component, newState, PackageManager.DONT_KILL_APP) - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) @@ -457,6 +405,8 @@ class MainActivity : AppCompatActivity(), MenuProvider { landingContainer.visibility = View.GONE documentContainer.visibility = View.VISIBLE + landingFragment?.setLandingVisible(false) + if (documentFragment == null) { documentFragment = DocumentFragment() supportFragmentManager @@ -635,19 +585,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { ttsActionMode = null } - private fun showRecent() { - val transaction = supportFragmentManager.beginTransaction() - - val chooserDialog: DialogFragment = RecentDocumentDialogFragment() - chooserDialog.show(transaction, RecentDocumentDialogFragment.FRAGMENT_TAG) - - analyticsManager.report( - AnalyticsConstants.EVENT_SELECT_CONTENT, - AnalyticsConstants.PARAM_CONTENT_TYPE, - "recent", - ) - } - private fun buyAdRemoval() { analyticsManager.report(AnalyticsConstants.EVENT_ADD_TO_CART) @@ -690,6 +627,10 @@ class MainActivity : AppCompatActivity(), MenuProvider { documentContainer.visibility = View.GONE landingContainer.visibility = View.VISIBLE + // the fragment is only hidden, not stopped, so it has to be told to pick the document + // that was just closed up into the recently opened list + landingFragment?.setLandingVisible(true) + analyticsManager.setCurrentScreen(this, "screen_main") } @@ -706,20 +647,13 @@ class MainActivity : AppCompatActivity(), MenuProvider { target.activityInfo.packageName == packageName || target.activityInfo.exported } - val targetNames = - (targetList.map { it.loadLabel(packageManager).toString() } + - getString(R.string.menu_recent)) - .toTypedArray() + // the recently opened documents used to be the last row here; they are the landing + // screen itself now + val targetNames = targetList.map { it.loadLabel(packageManager).toString() }.toTypedArray() val builder = AlertDialog.Builder(this) builder.setTitle(R.string.dialog_choose_filemanager) builder.setItems(targetNames) { dialog, which -> - if (which == targetNames.size - 1) { - showRecent() - - return@setItems - } - val target = targetList[which] intent.component = @@ -787,7 +721,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { const val SAVED_KEY_OPENED_EXTERNALLY = "OPENED_EXTERNALLY" const val GOOGLE_REQUEST_CODE = 1993 const val DOCUMENT_FRAGMENT_TAG = "document_fragment" - const val PREF_CATCH_ALL_ENABLED = "catch_all_enabled" // taken from: https://stackoverflow.com/a/36829889/198996 private fun isTesting(): Boolean = diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/LandingAdapter.kt b/app/src/main/java/app/opendocument/droid/ui/widget/LandingAdapter.kt new file mode 100644 index 000000000000..157a9c649576 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/LandingAdapter.kt @@ -0,0 +1,113 @@ +package app.opendocument.droid.ui.widget + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import app.opendocument.droid.R +import com.google.android.material.materialswitch.MaterialSwitch + +/** The rows of the landing screen: the recently opened documents and the catch-all setting. */ +class LandingAdapter(private val listener: Listener) : + ListAdapter(DIFF) { + + interface Listener { + + fun onDocumentClicked(document: LandingItem.Document) + + fun onDocumentRemoveRequested(document: LandingItem.Document) + + fun onCatchAllChanged(enabled: Boolean) + } + + override fun getItemViewType(position: Int): Int = + when (getItem(position)) { + is LandingItem.Header -> TYPE_HEADER + is LandingItem.Document -> TYPE_DOCUMENT + is LandingItem.CatchAll -> TYPE_CATCH_ALL + is LandingItem.Message -> TYPE_MESSAGE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + + val layout = + when (viewType) { + TYPE_HEADER -> R.layout.item_landing_header + TYPE_CATCH_ALL -> R.layout.item_landing_switch + TYPE_MESSAGE -> R.layout.item_landing_message + else -> R.layout.item_landing_row + } + + return ViewHolder(inflater.inflate(layout, parent, false)) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + when (val item = getItem(position)) { + is LandingItem.Header -> holder.title.setText(item.title) + + is LandingItem.Message -> holder.title.setText(item.text) + + is LandingItem.Document -> { + holder.icon.setImageResource(R.drawable.ic_description) + holder.title.text = item.filename + holder.itemView.setOnClickListener { listener.onDocumentClicked(item) } + holder.itemView.setOnLongClickListener { + listener.onDocumentRemoveRequested(item) + + true + } + } + + is LandingItem.CatchAll -> { + // set the state before the listener, so restoring it does not report a change + holder.switch.setOnCheckedChangeListener(null) + holder.switch.isChecked = item.checked + holder.switch.setOnCheckedChangeListener { _, isChecked -> + listener.onCatchAllChanged(isChecked) + } + + holder.itemView.setOnClickListener { holder.switch.toggle() } + } + } + } + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val icon: ImageView by lazy { view.findViewById(R.id.landing_row_icon) } + val title: TextView by lazy { view.findViewById(R.id.landing_row_title) } + val switch: MaterialSwitch by lazy { view.findViewById(R.id.landing_row_switch) } + } + + private companion object { + const val TYPE_HEADER = 0 + const val TYPE_DOCUMENT = 1 + const val TYPE_CATCH_ALL = 2 + const val TYPE_MESSAGE = 3 + + val DIFF = + object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: LandingItem, newItem: LandingItem): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: LandingItem, + newItem: LandingItem, + ): Boolean = + when { + oldItem is LandingItem.Document && newItem is LandingItem.Document -> + oldItem.filename == newItem.filename + + oldItem is LandingItem.CatchAll && newItem is LandingItem.CatchAll -> + oldItem.checked == newItem.checked + + // headers and messages are fully described by their id + else -> true + } + } + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/LandingItem.kt b/app/src/main/java/app/opendocument/droid/ui/widget/LandingItem.kt new file mode 100644 index 000000000000..d8658c7eec31 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/LandingItem.kt @@ -0,0 +1,28 @@ +package app.opendocument.droid.ui.widget + +import android.net.Uri +import androidx.annotation.StringRes + +/** One row of the landing screen. */ +sealed class LandingItem { + + /** The identity a row keeps across refreshes, so DiffUtil can tell a move from a change. */ + abstract val id: String + + class Header(@param:StringRes val title: Int) : LandingItem() { + override val id: String = "header:$title" + } + + class Document(val filename: String, val uri: Uri) : LandingItem() { + override val id: String = "document:$uri" + } + + class CatchAll(val checked: Boolean) : LandingItem() { + override val id: String = "catch_all" + } + + /** An explanatory line, used for the empty states of a section. */ + class Message(@param:StringRes val text: Int) : LandingItem() { + override val id: String = "message:$text" + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/RecentDocumentDialogFragment.kt b/app/src/main/java/app/opendocument/droid/ui/widget/RecentDocumentDialogFragment.kt deleted file mode 100644 index 22130138b375..000000000000 --- a/app/src/main/java/app/opendocument/droid/ui/widget/RecentDocumentDialogFragment.kt +++ /dev/null @@ -1,106 +0,0 @@ -package app.opendocument.droid.ui.widget - -import android.app.Dialog -import android.net.Uri -import android.os.Bundle -import android.view.View -import android.widget.AdapterView -import android.widget.AdapterView.OnItemClickListener -import android.widget.AdapterView.OnItemLongClickListener -import android.widget.ArrayAdapter -import android.widget.ListAdapter -import android.widget.ListView -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import app.opendocument.droid.R -import app.opendocument.droid.background.RecentDocumentsUtil -import app.opendocument.droid.ui.activity.MainActivity - -class RecentDocumentDialogFragment : - DialogFragment(), OnItemClickListener, OnItemLongClickListener { - - // insertion ordered: the adapter is built from keys, so a HashMap here would - // throw away the order RecentDocumentsUtil hands the documents back in - private var items: MutableMap = LinkedHashMap() - private lateinit var adapter: ListAdapter - private lateinit var listView: ListView - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireActivity()) - builder.setTitle(R.string.dialog_recent_title) - builder.setCancelable(true) - - val emptyView = TextView(requireActivity()) - emptyView.setText(R.string.dialog_loading_title) - - listView = ListView(requireActivity()) - listView.emptyView = emptyView - listView.onItemClickListener = this - listView.onItemLongClickListener = this - - adapter = - ArrayAdapter( - requireActivity(), - android.R.layout.simple_list_item_1, - emptyArray(), - ) - listView.adapter = adapter - - builder.setView(listView) - - setCancelable(true) - - items = LinkedHashMap() - - loadRecentDocuments() - - listView.emptyView = emptyView - - return builder.create() - } - - private fun loadRecentDocuments() { - items.clear() - for (entry in RecentDocumentsUtil.getRecentDocuments(requireActivity())) { - items[entry.filename] = entry.uri - } - - if (items.isEmpty()) { - items = LinkedHashMap() - items[getString(R.string.dialog_list_no_documents_found)] = null - } - - adapter = - ArrayAdapter( - requireActivity(), - android.R.layout.simple_list_item_1, - ArrayList(items.keys), - ) - - listView.adapter = adapter - } - - override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val key = adapter.getItem(position) as? String ?: return - - val uri = items[key] ?: return - - dismiss() - - (requireActivity() as MainActivity).loadUri(Uri.parse(uri)) - } - - override fun onItemLongClick( - parent: AdapterView<*>?, - view: View?, - position: Int, - id: Long, - ): Boolean { - return false - } - - companion object { - const val FRAGMENT_TAG: String = "document_chooser" - } -} diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 000000000000..1d6364d4a542 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_description.xml b/app/src/main/res/drawable/ic_description.xml new file mode 100644 index 000000000000..f28142e0a94f --- /dev/null +++ b/app/src/main/res/drawable/ic_description.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 000000000000..df71b00f05e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_landing.xml b/app/src/main/res/layout/fragment_landing.xml new file mode 100644 index 000000000000..2cd3940dd852 --- /dev/null +++ b/app/src/main/res/layout/fragment_landing.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_landing_header.xml b/app/src/main/res/layout/item_landing_header.xml new file mode 100644 index 000000000000..6061f8c9634f --- /dev/null +++ b/app/src/main/res/layout/item_landing_header.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/layout/item_landing_message.xml b/app/src/main/res/layout/item_landing_message.xml new file mode 100644 index 000000000000..b0ff9b85703c --- /dev/null +++ b/app/src/main/res/layout/item_landing_message.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/item_landing_row.xml b/app/src/main/res/layout/item_landing_row.xml new file mode 100644 index 000000000000..d155ed7db060 --- /dev/null +++ b/app/src/main/res/layout/item_landing_row.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_landing_switch.xml b/app/src/main/res/layout/item_landing_switch.xml new file mode 100644 index 000000000000..10b3eb286fb7 --- /dev/null +++ b/app/src/main/res/layout/item_landing_switch.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index ca30629777d9..98ed0f401450 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -1,13 +1,16 @@ + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_below="@id/ad_container" /> - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-normal/styles.xml b/app/src/main/res/values-normal/styles.xml deleted file mode 100644 index 9c89871557b2..000000000000 --- a/app/src/main/res/values-normal/styles.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-small/styles.xml b/app/src/main/res/values-small/styles.xml deleted file mode 100644 index 89896162bb10..000000000000 --- a/app/src/main/res/values-small/styles.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-xlarge/styles.xml b/app/src/main/res/values-xlarge/styles.xml deleted file mode 100644 index c2a843b39726..000000000000 --- a/app/src/main/res/values-xlarge/styles.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e31b696c94be..769d8f0577d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,10 +40,14 @@ Save Upload Edit your document below and press Save - Welcome to \nOpenDocument Reader - OpenDocument Reader allows you to view documents that are stored in OpenDocument format (.odt, .ods, .odp and .odg) wherever you are. - With OpenDocument Reader you can read and search through your documents in a breeze. - Found one last typo left to fix ahead of your big presentation? It now supports modifications too! + Recent + Settings + Open all file types + Open a file + Remove + Remove this document from the recent list? The document itself is not deleted. + Nothing here yet + Open a document to get started. It will show up here next time. By default this app only offers to open document files. If another app like \"Samsung My Files\" won\'t let you open a document here, turn this on to register for all file types. Reading… Initializing TTS… diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index d8f7dca1de56..000000000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d9b640d9d24..63aa2d876f3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ androidxAnnotation = "1.10.0" androidxAppcompat = "1.7.1" androidxCore = "1.19.0" androidxWebkit = "1.16.0" +androidxRecyclerview = "1.4.0" +androidxLifecycle = "2.9.4" material = "1.14.0" playServicesAds = "25.4.0" @@ -30,6 +32,12 @@ androidx-annotation ={ module = "androidx.annotation:annotation", version.ref = androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" } androidx-core = { module = "androidx.core:core", version.ref = "androidxCore" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidxWebkit" } +# recyclerview and lifecycle already arrive transitively through material, but the landing +# screen and DocumentFragment's ViewModel compile against them - declaring them keeps a +# material bump from silently taking a compile dependency away +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidxRecyclerview" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidxLifecycle" } +androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata", version.ref = "androidxLifecycle" } material = { module = "com.google.android.material:material", version.ref = "material" } play-services-ads = { module = "com.google.android.gms:play-services-ads", version.ref = "playServicesAds" }