diff --git a/CLAUDE.md b/CLAUDE.md index 897ac0ac0f12..2db3ef50f9b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,164 +1,153 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Development Commands - -### Building -- `./gradlew assembleProDebug` - Build the Pro debug variant -- `./gradlew assembleProDebugAndroidTest` - Build the Pro debug test APK -- `./gradlew assembleLiteDebug` - Build the Lite debug variant -- `./gradlew bundleProRelease` - Build Pro release bundle for Play Store -- `./gradlew bundleLiteRelease` - Build Lite release bundle for Play Store -- `./build-test.sh` - Convenience script to build Pro debug and test APKs - -### Testing -- `./gradlew testProDebugUnitTest` - Run JVM unit tests (no device needed) -- `./gradlew connectedAndroidTest` - Run instrumented tests on connected device -- `fastlane android tests` - Alternative way to run connected tests - -### Linting and formatting -- `./gradlew lintProDebug` - Run Android lint checks (errors fail the build) -- `./gradlew spotlessApply` - Apply formatting (google-java-format AOSP for java, - ktfmt kotlinlang style for kotlin) -- `./gradlew spotlessCheck` - Verify formatting; CI runs this first - -### Deployment -- `fastlane android deployPro` - Deploy Pro version to Google Play internal track -- `fastlane android deployLite` - Deploy Lite version to Google Play internal track - -### Clean -- `./gradlew clean` - Clean build artifacts (includes custom .cxx directory cleanup) - -## Architecture Overview - -### Core Components - -**Document Processing Pipeline:** -- `CoreLoader` - Primary document processor using the native C++ ODR core library -- `RawLoader` - Plain text and other raw file processor -- `OnlineLoader` - Remote document fetcher -- `MetadataLoader` - Document metadata extractor - -**Service Architecture:** -- `LoaderService` - Background service managing all document loading operations -- `LoaderServiceQueue` - Queue management for multiple document loading requests -- Document loaders implement `FileLoaderListener` interface for async communication - -**UI Architecture:** -- `MainActivity` - Main activity with service binding and menu management -- `DocumentFragment` - Primary document display fragment using WebView -- `PageView` - Custom WebView for document rendering -- Action mode callbacks for edit, find, and TTS functionality - -### Build System - -**Multi-flavor Android App:** -- **Lite flavor**: Free version with ads and tracking enabled -- **Pro flavor**: Paid version with ads disabled and tracking disabled - -**Native Dependencies:** -- Uses Conan package manager for C++ dependencies -- The app compiles no native code itself; conan builds odrcore (including its JNI - bindings) and the deployer drops the resulting `.so` files into `jniLibs` -- NDK version 28.2.13676358 required (for conan's cross build, and for the - `libc++_shared.so` that gets shipped alongside `libodr_jni.so`) -- C++20 standard -- `conan` is taken from PATH; override with `-Podr.conanExecutable=...` or `ODR_CONAN`. - Note the conan gradle plugin does not track `app/conanprofile.txt` as a task input, so - after editing it `conanInstall-*` stays UP-TO-DATE and the native libs keep their old - settings - run the conan install by hand to pick the change up locally. - -**Core Library Integration:** -- The JNI interface comes from odrcore itself, both halves out of the one conan package - built with the recipe's `with_jni` option: java classes under `app.opendocument.core` - from `share/java/odr-core-java.jar`, and the matching `libodr_jni.so`. `CoreLoader` is - the only thing wrapping it. -- Taking both from the same package is deliberate and should stay that way - handles +Guidance for Claude Code (claude.ai/code) working in this repository. + +## Commands + +- Build: `./gradlew assembleProDebug` (also `assembleLiteDebug`, `bundleProRelease`, + `bundleLiteRelease`). `./build-test.sh` builds the pro debug app plus its test apk. +- Test: `./gradlew testProDebugUnitTest` (jvm, no device), `./gradlew connectedAndroidTest` + (needs a device or emulator). +- Format and lint: `./gradlew spotlessApply` / `spotlessCheck` (google-java-format AOSP for + java, ktfmt kotlinlang for kotlin) and `./gradlew lintProDebug`. Lint errors fail the + build; CI runs spotless first. +- Deploy: `fastlane android deployPro` / `deployLite` to the Play internal track. +- `./gradlew clean` also clears the `.cxx` directory. + +## Architecture + +A document is loaded by a `FileLoader` on `LoaderService`'s background thread and reported +back through `FileLoaderListener`; `LoaderServiceQueue` holds requests until the service is +bound. `MetadataLoader` caches the file and identifies it, `CoreLoader` renders what odrcore +handles and publishes the html on a local http server, `RawLoader` covers text, csv and +images, and `OnlineLoader` uploads to a web viewer what neither can open. + +`MainActivity` owns the service binding and the action modes (find, tts, edit), and swaps +between two fragments: `LandingFragment` (recent documents, granted folders, settings) and +`DocumentFragment`, which shows the result in `PageView` - a WebView - with `DocumentActions` +over it. + +Source sits under `app/src/main/java/app/opendocument/droid/`: `background/` for the loaders +and stored state, `ui/` for everything on screen, `nonfree/` for analytics, billing and ads. + +## Build + +Two flavors: **lite** is free with ads and tracking, **pro** is paid with neither. The +switch is a `DISABLE_TRACKING` resource bool set per flavor in `app/build.gradle`, which the +`nonfree/` managers read to disable themselves; `app/src/pro/` holds nothing but a manifest. + +Minimum SDK 26, target 36, compile 37 (ahead of target on purpose). AGP 9 / Gradle 9, with +no kotlin plugin applied - AGP brings kotlin itself. Versions live in +`gradle/libs.versions.toml`. R8 and resource shrinking are on for release, the configuration +cache is on, and release signing comes from gradle properties or the environment (see +README) - without them release variants build unsigned rather than failing. + +**The version is the git tag.** `app/build.gradle` derives both `versionName` and +`versionCode` from `-Podr.version` (`v4.8.0` -> `4.8.0` / `40800`, two digits per part, a +part above 99 is an error), and defaults to `0.0.0`. Do not put the attributes back into +`AndroidManifest.xml`: gradle's values win in the merge, so a second copy can only ever +disagree with the tag. + +### Native side + +The app compiles no native code. Conan builds odrcore for armv8, armv7, x86 and x86_64, and +`app/conandeployer.py` drops the `.so` files into `jniLibs/` and the core's assets into +`assets/core`. Needs NDK 28.2.13676358 and C++20. `conan` comes from PATH, overridable with +`-Podr.conanExecutable=...` or `ODR_CONAN`. + +- The conan gradle plugin does not treat `app/conanprofile.txt` as a task input, so after + editing it `conanInstall-*` stays UP-TO-DATE and the native libs silently keep their old + settings. Run the conan install by hand locally. +- **Both halves of the JNI interface come out of the one odrcore package**, built with the + recipe's `with_jni` option: the `app.opendocument.core` classes from + `share/java/odr-core-java.jar` and the matching `libodr_jni.so`. Keep it that way. Handles cross as raw longs and enums as ordinals with no version negotiation, so a separately - versioned java artifact could drift from the `.so`. It also keeps the build free of - credentials, which f-droid and other clean source builders need. `conandeployer.py` - puts the jar in `build/conan//libs` and `app/build.gradle` depends on the armv8 - copy as a file, not through a repository. -- Anything the bindings use must exist on **android API 26**, which is far below what - their `--release 17` compiler accepts. That is a runtime-only failure, on device - (`java.lang.ref.Cleaner` and `List.of` both had to be fixed upstream for this reason) -- Supports multiple architectures: armv8, armv7, x86, x86_64 -- Assets deployed to `assets/core` and native libraries to `jniLibs/` via the custom - Conan deployer (`app/conandeployer.py`) - -### Key Directories - -- `app/src/main/java/app/opendocument/droid/background/` - Document processing services -- `app/src/main/java/app/opendocument/droid/ui/` - UI components and activities -- `app/src/main/java/app/opendocument/droid/nonfree/` - Analytics, billing, and ads -- `app/src/main/assets/` - HTML templates and fonts for document rendering - -### Dependencies - -**Core Android:** -- AndroidX libraries (AppCompat, Core, Material, WebKit) -- Google Play Services (Ads, Review, User Messaging Platform) - -**Document Processing:** -- Custom ODR core library via Conan - -**Testing:** -- Espresso for UI testing -- JUnit for unit testing -- Test APKs require connected device/emulator - -### Configuration Notes - -- Minimum SDK: 26, Target SDK: 36, Compile SDK: 37 (ahead of target on purpose) -- AGP 9 / Gradle 9, versions live in `gradle/libs.versions.toml` -- R8/ProGuard enabled for release builds with resource shrinking -- Configuration cache enabled for parallel Conan installs -- Release signing credentials come from gradle properties or environment variables (see - README); without them release variants build unsigned rather than failing -- The version is the git tag, not a number in the tree. `AndroidManifest.xml` carries no - `versionCode`/`versionName`; `app/build.gradle` derives both from `-Podr.version` - (`v4.8.0` -> name `4.8.0`, code `40800`, two digits per part, parts above 99 are an - error), and a build handed no version is `0.0.0`. Do not put the attributes back in the - manifest: gradle's values win in the merged manifest, so a second copy can only ever - disagree with the tag. The release workflow passes the tag it ran on; a dispatched run - passes its `version` input - -### Package names - -`namespace` (`app.opendocument.droid`) and `applicationId` (`at.tomtasche.reader`, plus -the `.pro` suffix) differ on purpose - do not "fix" the mismatch: - -- `namespace` is only the java/kotlin package plus `R`/`BuildConfig`, and is free to - rename. Nothing native is bound to it anymore - the JNI symbols live in odrcore's own - `app.opendocument.core` package, which the app does not rename - so the keeps in - `proguard-rules.txt` are about that package, not this one. -- `applicationId` is the identity on Play and F-Droid and can never change: Play has no - rename path, so a new one is a new listing that existing installs never update to. - Store-facing renaming goes through the listing title in `fastlane/metadata/`. -- The component names in `AndroidManifest.xml` (`MainActivity` and the `CATCH_ALL` / - `STRICT_CATCH` aliases) also still read `at.tomtasche.reader.*` on purpose. The OS - persists those strings for pinned launcher icons and for "always open .odt with this - app", so they survive as `activity-alias` entries pointing at the relocated activity. - The `ComponentName` strings in `MainActivity` must keep matching them. -- Anything reading `getPackageName()` at runtime (the FileProvider authority in - `AndroidFileCache`, the SharedPreferences name in `MainActivity`) follows - `applicationId` and must stay that way, or existing users lose their saved prefs. - -### Language - -Kotlin; support is built into AGP 9, no kotlin plugin is applied. The only java left is -`com/commonsware/android/print`, which is vendored third party code with its own copyright -header and stays java so it can still be diffed against upstream. It calls nothing of ours, -so there is no java-to-kotlin call anywhere in the project. - -That means `@JvmStatic`, `@JvmField`, `@JvmOverloads` and `@Throws` are not needed for -interop and should not be added back for it. What is left of them is there for a runtime -that reflects over the bytecode, and each one says so: - -- `@JvmField` on the `CREATOR`s in `FileLoader`, which the parcelable contract requires to - be a static field. -- `@JvmOverloads` on `ProgressDialogFragment`'s constructor, so the no-arg constructor the - fragment framework re-creates it with exists. -- `@JvmStatic` on the `@BeforeClass` / `@AfterClass` methods in the instrumented tests, - which JUnit requires to be static. + versioned java artifact could drift from the `.so` - and depending on the jar as a file + rather than through a repository keeps the build credential free, which f-droid and other + clean source builders need. `CoreLoader` is the only thing wrapping any of it. +- Anything the bindings use must exist on **API 26**, far below what their `--release 17` + compiler accepts. It fails only at runtime, on device: `java.lang.ref.Cleaner` and + `List.of` both had to be fixed upstream for this. + +## Rules that are easy to break + +### The package names differ on purpose + +`namespace` is `app.opendocument.droid`, `applicationId` is `at.tomtasche.reader` (plus a +`.pro` suffix). Do not "fix" the mismatch. + +- `namespace` is only the java/kotlin package plus `R`/`BuildConfig` and is free to rename. + The keeps in `proguard-rules.txt` are about odrcore's own `app.opendocument.core`, not + this. +- `applicationId` is the identity on Play and F-Droid and can never change - a new one is a + new listing that existing installs never update to. Store-facing renaming goes through the + listing title in `fastlane/metadata/`. +- The `MainActivity` and `CATCH_ALL` / `STRICT_CATCH` component names in the manifest keep + their `at.tomtasche.reader.*` spelling, as `activity-alias` entries pointing at the + relocated activity: the OS persists those strings for pinned launcher icons and for + "always open .odt with this app". The `ComponentName` strings in `MainActivity` must keep + matching them. +- Whatever reads `getPackageName()` at runtime - the FileProvider authority in + `AndroidFileCache`, the preferences name in `MainActivity` - follows `applicationId` and + must stay that way, or upgrading users lose their saved settings. + +### Supported file types are declared twice + +`SupportedDocumentTypes` is the one kotlin table: mime prefixes for what the core renders, +mime prefixes for what `RawLoader` shows, and an extension fallback for the +`application/octet-stream` a provider volunteers when it knows nothing better. +`CoreLoader.isSupported()` defers to it. The set follows odrcore - odf, ooxml, the legacy +binary doc/ppt/xls and pdf go to the core; text, csv and images are the core's too but +belong to `RawLoader`, which only gets its turn when the core loader says no. + +The `STRICT_CATCH` `activity-alias` in `AndroidManifest.xml` is the second declaration and +cannot be collapsed into the first, because XML cannot read a kotlin list. +`SupportedFormatsTest` keeps the two honest instead, so that adding a format on one side and +forgetting the other fails CI: it walks odrcore's `FileType` values and demands that the +table and the package manager agree, and separately pins the ooxml templates, slideshows and +macro-enabled variants, which that walk cannot reach. + +The app needs a table of its own because `Odr.fileTypeByMimetype` / `fileTypeByFileExtension` +know exactly one canonical mime type per format - no templates, no macro-enabled variants, +no `application/x-vnd.oasis...`, which are the spellings providers actually hand out, and an +intent-filter matches a mime type exactly. Guessing has to be broader than the core's own +lookup. Everything after the file is cached is the core's call though: `MetadataLoader` runs +libmagic over the copy, and editability comes from the opened document. + +### Editability comes from the core, never from a mime type + +`Document.isEditable()`/`isSavable()` decides whether `DocumentFragment` offers the Edit +button, carried across on `FileLoader.Result.isEditable`. `CoreLoader.host()` only holds a +document open when the core says yes, so having one *is* the answer. + +Do not reintroduce a list of editable formats in the UI. The core says no to the legacy +binary formats, to ooxml spreadsheets and presentations, and to every spreadsheet including +odf (its own TODO, the same gap as issue #442) - all of which the app used to spell out by +mime prefix and got wrong the moment a new format started going through `CoreLoader`. + +### Storage access + +The app declares **no storage permission at all**, only `INTERNET`, and it has to stay that +way: `READ_EXTERNAL_STORAGE` has not reached documents since scoped storage, `READ_MEDIA_*` +covers only images, video and audio, and Play restricts `MANAGE_EXTERNAL_STORAGE` to file +managers and backup apps. + +Everything goes through SAF instead - `ACTION_OPEN_DOCUMENT` for a single file and +`ACTION_OPEN_DOCUMENT_TREE` (read only, never `FLAG_GRANT_WRITE_URI_PERMISSION`) for the +folders the landing screen browses. `PersistedUriPermissions` persists those grants and +reclaims them by reconciling against the recent list and the granted trees, rather than +releasing on close. Do not add a release next to a `documentFragment.loadUri()`: that call +only queues the load, so the stream is opened long after it returns. + +### Kotlin, and the three `@Jvm` annotations left + +The only java is `com/commonsware/android/print`, vendored third party code kept in java so +it can still be diffed against upstream. It calls nothing of ours, so no java-to-kotlin call +exists anywhere in the project - `@JvmStatic`, `@JvmField`, `@JvmOverloads` and `@Throws` +are not needed for interop and should not be added back for it. + +What remains is there for runtimes that reflect over the bytecode: `@JvmField` on +`FileLoader`'s `CREATOR`s (parcelable requires a static field), `@JvmOverloads` on +`ProgressDialogFragment`'s constructor (the fragment framework re-creates it with no +arguments), and `@JvmStatic` on `@BeforeClass` / `@AfterClass` in the instrumented tests +(junit requires them static). diff --git a/app/build.gradle b/app/build.gradle index d9535cb81ff7..d1f771b22fcb 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 @@ -251,6 +254,9 @@ dependencies { 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 diff --git a/app/src/androidTest/assets/11KB.doc b/app/src/androidTest/assets/11KB.doc new file mode 100644 index 000000000000..3940e5d26a65 Binary files /dev/null and b/app/src/androidTest/assets/11KB.doc differ diff --git a/app/src/androidTest/assets/file_example_XLS_10.xls b/app/src/androidTest/assets/file_example_XLS_10.xls new file mode 100644 index 000000000000..1464ee150a46 Binary files /dev/null and b/app/src/androidTest/assets/file_example_XLS_10.xls differ diff --git a/app/src/androidTest/assets/style-various-1.ppt b/app/src/androidTest/assets/style-various-1.ppt new file mode 100644 index 000000000000..330883837974 Binary files /dev/null and b/app/src/androidTest/assets/style-various-1.ppt differ diff --git a/app/src/androidTest/assets/style-various-1.pptx b/app/src/androidTest/assets/style-various-1.pptx new file mode 100644 index 000000000000..527847468a6f Binary files /dev/null and b/app/src/androidTest/assets/style-various-1.pptx differ diff --git a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt index f09fcf29e138..f829aa4553a6 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt @@ -31,6 +31,10 @@ class CoreTest { private var passwordTestFile: File? = null private var spreadsheetTestFile: File? = null private var docxTestFile: File? = null + private var pptxTestFile: File? = null + private var docTestFile: File? = null + private var pptTestFile: File? = null + private var xlsTestFile: File? = null private val coreLoader: CoreLoader get() = checkNotNull(sharedLoader) { "the core loader was not started" } @@ -58,6 +62,22 @@ class CoreTest { assetManager.open("style-various-1.docx"), File(appCtx.cacheDir, "style-various-1.docx"), ) + pptxTestFile = + extract( + assetManager.open("style-various-1.pptx"), + File(appCtx.cacheDir, "style-various-1.pptx"), + ) + docTestFile = extract(assetManager.open("11KB.doc"), File(appCtx.cacheDir, "11KB.doc")) + pptTestFile = + extract( + assetManager.open("style-various-1.ppt"), + File(appCtx.cacheDir, "style-various-1.ppt"), + ) + xlsTestFile = + extract( + assetManager.open("file_example_XLS_10.xls"), + File(appCtx.cacheDir, "file_example_XLS_10.xls"), + ) } @After @@ -66,6 +86,10 @@ class CoreTest { passwordTestFile?.delete() spreadsheetTestFile?.delete() docxTestFile?.delete() + pptxTestFile?.delete() + docTestFile?.delete() + pptTestFile?.delete() + xlsTestFile?.delete() } @Test @@ -109,6 +133,90 @@ class CoreTest { Assert.assertTrue("the edited document should have been saved", result.isFile) } + /** + * The formats that used to be sent off for conversion instead: presentations, and the legacy + * binary microsoft ones. The core renders all four, so the app hands them to it. + */ + @Test + fun testPptx() { + val views = + coreLoader.host( + prefix = "pptx-test", + inputPath = requireFile(pptxTestFile).absolutePath, + cachePath = File(cacheDir(), "pptx_cache").path, + ) + Assert.assertFalse("hosting the PPTX file should produce a view", views.isEmpty()) + } + + @Test + fun testDoc() { + val views = + coreLoader.host( + prefix = "doc-test", + inputPath = requireFile(docTestFile).absolutePath, + cachePath = File(cacheDir(), "doc_cache").path, + ) + Assert.assertFalse("hosting the DOC file should produce a view", views.isEmpty()) + } + + @Test + fun testPpt() { + val views = + coreLoader.host( + prefix = "ppt-test", + inputPath = requireFile(pptTestFile).absolutePath, + cachePath = File(cacheDir(), "ppt_cache").path, + ) + Assert.assertFalse("hosting the PPT file should produce a view", views.isEmpty()) + } + + @Test + fun testXls() { + val views = + coreLoader.host( + prefix = "xls-test", + inputPath = requireFile(xlsTestFile).absolutePath, + cachePath = File(cacheDir(), "xls_cache").path, + ) + Assert.assertFalse("hosting the XLS file should produce a view", views.isEmpty()) + } + + /** + * Which of the formats the core renders it can also write back again - the answer + * `DocumentFragment` puts the Edit button up by. Offering it for a document the core will not + * save only gets as far as a failed save, which is what happened to the legacy binary formats + * when they started going through the core. + */ + @Test + fun testEditableFormats() { + assertEditable("odt-editable", requireFile(testFile), true) + assertEditable("docx-editable", requireFile(docxTestFile), true) + + // the core declares these read only: the three legacy binary formats, ooxml presentations + // and every spreadsheet - the last being issue #442, which the core has its own TODO for + assertEditable("doc-editable", requireFile(docTestFile), false) + assertEditable("ppt-editable", requireFile(pptTestFile), false) + assertEditable("xls-editable", requireFile(xlsTestFile), false) + assertEditable("pptx-editable", requireFile(pptxTestFile), false) + assertEditable("ods-editable", requireFile(spreadsheetTestFile), false) + } + + private fun assertEditable(prefix: String, file: File, expected: Boolean) { + coreLoader.host( + prefix = prefix, + inputPath = file.absolutePath, + cachePath = File(cacheDir(), prefix).path, + editable = true, + keepDocument = true, + ) + + Assert.assertEquals( + "the core should report ${file.name} as ${if (expected) "editable" else "read only"}", + expected, + coreLoader.isDocumentEditable, + ) + } + @Test fun testPasswordProtectedDocumentWithoutPassword() { Assert.assertThrows(OdrException.FileEncrypted::class.java) { @@ -176,7 +284,7 @@ class CoreTest { // set of statics. nothing here goes through loadAsync, so both handlers can be the main // looper and the listener is never called back val handler = Handler(Looper.getMainLooper()) - val loader = CoreLoader(appCtx, true) + val loader = CoreLoader(appCtx) sharedLoader = loader loader.initialize( object : FileLoader.FileLoaderListener { 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..109a731ebf61 --- /dev/null +++ b/app/src/androidTest/java/app/opendocument/droid/test/LandingTests.kt @@ -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( + 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) } } + } + } +} diff --git a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt index 67dd71ca98fa..af41435a854c 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -16,14 +16,13 @@ import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.IdlingResource import androidx.test.espresso.action.ViewActions.clearText import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withClassName -import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -47,8 +46,6 @@ import java.net.URL import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -import org.hamcrest.Matchers.allOf -import org.hamcrest.Matchers.anyOf import org.hamcrest.Matchers.equalTo import org.junit.After import org.junit.AfterClass @@ -120,7 +117,11 @@ class MainActivityTests { // next onView will be blocked until the idling resource is idle, which now covers // the load itself and not just the picker round trip. - clickEditWithOverflowFallback() + waitForDocumentActions() + + unfoldDocumentActions() + + onView(withText(R.string.menu_edit)).check(matches(isDisplayed())) } @Test @@ -131,7 +132,7 @@ class MainActivityTests { // next onView will be blocked until the idling resource is idle, which now covers // the load itself and not just the picker round trip. - clickEditWithOverflowFallback() + waitForDocumentActions() // there used to be a 10s sleep here that asserted nothing, and it is why "testPDF // crashed" was an api 34 bucket of its own: the app is killed whenever play services @@ -173,7 +174,8 @@ class MainActivityTests { // Enter wrong password first onView(withClassName(equalTo("android.widget.EditText"))).perform(typeText("wrongpassword")) - onView(withId(android.R.id.button1)).perform(click()) + // typing leaves the keyboard up, and on a short screen it covers the dialog's buttons + onView(withId(android.R.id.button1)).perform(closeSoftKeyboard(), click()) // Should show password dialog again for wrong password onView(withText("This document is password-protected")).check(matches(isDisplayed())) @@ -182,10 +184,10 @@ class MainActivityTests { onView(withClassName(equalTo("android.widget.EditText"))) .perform(clearText(), typeText("passwort")) - onView(withId(android.R.id.button1)).perform(click()) + onView(withId(android.R.id.button1)).perform(closeSoftKeyboard(), click()) - // Check if edit button becomes available (indicating successful load) - clickEditWithOverflowFallback() + // Check if the document buttons become available (indicating successful load) + waitForDocumentActions() } @Test @@ -254,43 +256,32 @@ class MainActivityTests { ) } + // the landing screen is the only thing offering to open a document now that the toolbar + // action is gone. the fab is the entry point that is there whatever the list holds - the + // empty state has one of its own, but only while it is up, so matching either would be + // ambiguous rather than tolerant. + // + // closeSoftKeyboard first, and it is not decoration: the fab sits in the lower half of the + // screen, where a keyboard left over from an earlier test covers it. the ime window is + // above ours, so the tap lands on it, espresso reports the click as performed, and nothing + // happens - see the note on waitForDocumentActions. private fun openDocumentThroughPicker() { - onView( - allOf( - withId(R.id.menu_open), - withContentDescription("Open document"), - isDisplayed(), - ) - ) - .perform(click()) - - // The menu item could be either Documents or Files. - onView( - allOf( - withId(android.R.id.text1), - anyOf(withText("Documents"), withText("Files")), - isDisplayed(), - ) - ) - .perform(click()) + onView(withId(R.id.landing_open_fab)).perform(closeSoftKeyboard(), click()) } - private fun clickEditWithOverflowFallback() { - onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isEnabled())) - .withFailureHandler { _, _ -> - // fails on small screens, try again with overflow menu - onView(allOf(withContentDescription("More options"), isDisplayed())) - .perform(click()) - - onView( - allOf( - withId(R.id.menu_edit), - withContentDescription("Edit document"), - isDisplayed(), - ) - ) - .perform(click()) - } + // the buttons of the open document are up once it has loaded, so this blocks on the idling + // resource and then says whether anything came of the load. only the button that unfolds the + // rest is checked: what is inside it differs per format, a pdf cannot be edited. + // + // a click that never reached the app fails here rather than where it happened: nothing was + // ever queued, so the idling resource stays idle, this does not wait, and the landing screen + // is still what is on screen. + private fun waitForDocumentActions() { + onView(withId(R.id.document_actions_more)).check(matches(isDisplayed())) + } + + private fun unfoldDocumentActions() { + onView(withId(R.id.document_actions_more)).perform(click()) } private fun recreate(activity: MainActivity): MainActivity? { diff --git a/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt b/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt new file mode 100644 index 000000000000..fadf868f7300 --- /dev/null +++ b/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt @@ -0,0 +1,201 @@ +package app.opendocument.droid.test + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import app.opendocument.core.FileType +import app.opendocument.core.Odr +import app.opendocument.droid.background.CatchAllSetting +import app.opendocument.droid.background.CoreLoader +import app.opendocument.droid.background.SupportedDocumentTypes +import org.junit.Assert +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Keeps the two remaining declarations of what the app opens in step with each other, so that + * changing one and forgetting the other is a failing test rather than a bug report. + * + * [SupportedDocumentTypes] is one of them. The other is the STRICT_CATCH `activity-alias` in + * AndroidManifest.xml, which is XML and cannot read a kotlin list - but it can be *asked*: an + * intent-filter is only worth anything if the package manager resolves a real intent through it, + * which is what [resolvesToUs] does here. There used to be a third copy in `CoreLoader`; that one + * is gone, it defers to [SupportedDocumentTypes] now. + * + * The mime side needs no list of its own at all: [FileType] is the core's complete set of formats + * and [Odr.mimetypeByFileType] names each one, so the test walks what odrcore knows and demands + * that the app and the manifest give the same answer for every entry. + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class SupportedFormatsTest { + + /** + * For every format odrcore can name a mime type for, what the app offers itself for and what + * the manifest offers it for have to be the same answer. + * + * This is the assertion that catches a format being added to the loader without being added to + * the manifest, which is the failure the user sees as "the app is not in the share sheet". + */ + @Test + fun theManifestClaimsExactlyWhatTheAppClaims() { + var checked = 0 + + for (fileType in FileType.values()) { + val mimeType = mimeTypeOrNull(fileType) ?: continue + checked++ + + val claimedByApp = SupportedDocumentTypes.isSupported(mimeType, null) + val claimedByManifest = resolvesToUs(mimeType, "document") + + Assert.assertEquals( + "$fileType ($mimeType): the app says $claimedByApp, the manifest says" + + " $claimedByManifest - AndroidManifest.xml and SupportedDocumentTypes have" + + " drifted apart", + claimedByApp, + claimedByManifest, + ) + } + + // a guard on the guard: if the core ever stops naming mime types the loop above would + // silently assert nothing at all + Assert.assertTrue("no file type was checked", checked > 10) + } + + /** Every extension the folder browser offers for has to be one odrcore actually recognises. */ + @Test + fun theExtensionFallbackNamesFormatsTheCoreKnows() { + for (extension in SupportedDocumentTypes.EXTENSIONS) { + Assert.assertNotEquals( + "odrcore does not know the extension .$extension", + FileType.UNKNOWN, + Odr.fileTypeByFileExtension(extension), + ) + } + } + + /** + * The case the extension fallback exists for: a provider that volunteers nothing better than + * `application/octet-stream`. The app goes by the name, and the manifest has to as well - it + * claims `application/octet-stream` outright, so this is really a check that the claim stays. + */ + @Test + fun anOctetStreamWithAKnownExtensionReachesUs() { + for (extension in SupportedDocumentTypes.EXTENSIONS) { + Assert.assertTrue( + "the app does not offer for document.$extension", + SupportedDocumentTypes.isSupported( + "application/octet-stream", + "document.$extension", + ), + ) + Assert.assertTrue( + "the manifest does not offer for document.$extension", + resolvesToUs("application/octet-stream", "document.$extension"), + ) + } + } + + /** + * The spellings the mime *prefixes* carry along - the ooxml templates, slideshows and macro + * enabled variants - which [theManifestClaimsExactlyWhatTheAppClaims] cannot reach: odrcore + * names one canonical mime type per format, so its [FileType] walk never produces any of these. + * + * They still have to hold, because an intent-filter matches a mime type exactly. Without them + * spelled out in the manifest a .dotx opened from the folder browser while the same file from + * another app did not offer OpenDocument at all. + */ + @Test + fun theOoxmlVariantsReachUsToo() { + val variants = + listOf( + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.ms-word.document.macroEnabled.12", + "application/vnd.ms-word.template.macroEnabled.12", + "application/vnd.ms-excel.sheet.macroEnabled.12", + "application/vnd.ms-excel.template.macroEnabled.12", + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + "application/vnd.ms-powerpoint.template.macroEnabled.12", + "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + ) + + for (mimeType in variants) { + Assert.assertTrue( + "the app does not offer for $mimeType", + SupportedDocumentTypes.isSupported(mimeType, null), + ) + Assert.assertTrue( + "the manifest does not offer for $mimeType", + resolvesToUs(mimeType, "document"), + ) + } + } + + /** What the app deliberately stays out of - the reason the catch-all filter defaults to off. */ + @Test + fun unrelatedTypesReachNeither() { + for (mimeType in listOf("text/vcard", "text/calendar", "audio/mpeg", "video/mp4")) { + Assert.assertFalse( + "the app should not offer for $mimeType", + SupportedDocumentTypes.isSupported(mimeType, null), + ) + Assert.assertFalse( + "the manifest should not offer for $mimeType", + resolvesToUs(mimeType, "document"), + ) + } + } + + /** + * Whether an `ACTION_VIEW` for this mime type and filename lands on our own activity - the only + * thing an intent-filter is actually good for, and the one question a hand written XML list can + * still be asked. + */ + private fun resolvesToUs(mimeType: String, filename: String): Boolean { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val intent = + Intent(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + setDataAndType(Uri.parse("content://app.opendocument.test/$filename"), mimeType) + } + + return context.packageManager + .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .any { it.activityInfo?.packageName == context.packageName } + } + + /** Null for the types odrcore has no mime type for - word perfect, rtf, cfb and the like. */ + private fun mimeTypeOrNull(fileType: FileType): String? = + try { + Odr.mimetypeByFileType(fileType) + } catch (e: RuntimeException) { + null + } + + companion object { + + // @JvmStatic because junit requires @BeforeClass to be static + @JvmStatic + @BeforeClass + fun prepare() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Odr's tables live in libodr_jni; nothing here opens a file, so the data paths that + // initializeCore also sets up are along for the ride rather than needed + CoreLoader.initializeCore(context) + + // STRICT_CATCH is what this test is about, and the component states survive a test run + // - a previous one that flipped the switch would otherwise leave CATCH_ALL answering + // for everything. False is the shipped default, so nothing has to be put back. + CatchAllSetting.setEnabled(context, false) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f85dfdccc660..70f25b5adfff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,9 +72,15 @@ activity-alias name does not have to correspond to a real class, so the old names live on as aliases pointing at the relocated activity. --> + + @@ -253,7 +259,28 @@ + + + + + + + + + + + + + + + @@ -325,6 +352,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -413,6 +461,27 @@ + + + + + + + + + + + + + + + + + + + + + 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/background/CoreLoader.kt b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt index 887f2c1bb97a..f062007df1d3 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -9,7 +9,6 @@ import app.opendocument.core.DecodePreference import app.opendocument.core.DecodedFile import app.opendocument.core.Document import app.opendocument.core.DocumentType -import app.opendocument.core.FileType import app.opendocument.core.GlobalParams import app.opendocument.core.Html import app.opendocument.core.HtmlConfig @@ -43,8 +42,7 @@ import java.io.IOException */ // the context is nullable like FileLoader's own field, which close() clears and the unit // tests never set - isSupported() is pure and does not need one -class CoreLoader(context: Context?, private val doOoxml: Boolean) : - FileLoader(context, LoaderType.CORE) { +class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { private var server: HttpServer? = null private var httpThread: Thread? = null @@ -53,6 +51,18 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : private var lastInputPath: String? = null private var lastCachePath: String? = null + /** + * Whether the document [host] last opened is one [edit] can do something with. + * + * This is the core's own answer, not a list of formats kept here: [host] only holds a document + * open when it reports itself editable and savable, so having one is the answer. The core says + * no to the legacy binary formats, to ooxml spreadsheets and presentations and to opendocument + * spreadsheets - the last of those being the same gap as issue #442, which the app used to + * check for by mime type on its own. + */ + val isDocumentEditable: Boolean + get() = document != null + // the port the server actually got, which is not always the preferred one. see bind() private var serverPort = PREFERRED_SERVER_PORT @@ -131,23 +141,17 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : else FALLBACK_SERVER_PORT } - override fun isSupported(options: Options): Boolean { - val fileType = options.fileType ?: return false - return fileType.startsWith("application/vnd.oasis.opendocument") || - fileType.startsWith("application/x-vnd.oasis.opendocument") || - fileType.startsWith("application/vnd.oasis.opendocument.text-master") || - fileType.startsWith("application/msword") || - (doOoxml && - (fileType.startsWith( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) || - fileType.startsWith( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) - // TODO: enable pptx too - // fileType.startsWith("application/vnd.openxmlformats-officedocument.presentationml.presentation") - )) - } + /** + * What the core renders itself, out of [SupportedDocumentTypes] so the folder browser and this + * cannot disagree about it. + * + * Text, csv and images are left out although the core takes those too - [RawLoader] is what + * gives them their player or their viewer, and this answer is what lets it have its turn. The + * rest of it decides whether a failed load is worth reporting and which viewer [OnlineLoader] + * falls back to. + */ + override fun isSupported(options: Options): Boolean = + SupportedDocumentTypes.isRenderedByCore(options.fileType) override fun loadSync(options: Options) { val result = Result(type, options) @@ -191,6 +195,8 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : keepDocument = true, ) + result.isEditable = isDocumentEditable + for (view in views) { result.partTitles.add(view.name) result.partUris.add(Uri.parse(view.url)) @@ -232,10 +238,18 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : if (keepDocument) { closeDocument() - // .doc-files are not real documents in core - if (file.isDocumentFile && file.fileType() != FileType.LEGACY_WORD_DOCUMENT) { + if (file.isDocumentFile) { // TODO this will cause a second load - document = file.asDocumentFile().document() + val document = file.asDocumentFile().document() + + // keep only what [edit] can do something with, and let the core be the one to say + // so - see [isDocumentEditable]. Holding a read only document open buys a second + // parse and nothing else. + if (document.isEditable && document.isSavable) { + this.document = document + } else { + document.close() + } } } diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentTreeBrowser.kt b/app/src/main/java/app/opendocument/droid/background/DocumentTreeBrowser.kt new file mode 100644 index 000000000000..a205055eea13 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/DocumentTreeBrowser.kt @@ -0,0 +1,119 @@ +package app.opendocument.droid.background + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract + +/** + * Lists what is inside a directory tree the user granted access to. + * + * Uses [DocumentsContract] rather than androidx's DocumentFile: DocumentFile.listFiles() issues a + * fresh query per child as soon as the name or type is asked for, which is one binder round trip + * per file. One cursor over the whole folder is what this needs instead. + */ +object DocumentTreeBrowser { + + class Child(val documentId: String, val displayName: String, val mimeType: String) { + val isDirectory: Boolean + get() = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + } + + /** + * The supported documents and the sub directories of [parentDocumentId], directories first and + * then by name. + * + * Does binder work, so it must not run on the main thread. + */ + fun listChildren(context: Context, treeUri: Uri, parentDocumentId: String): List { + val childrenUri = + DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId) + + val children = ArrayList() + + try { + context.contentResolver + .query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ), + null, + null, + null, + ) + .use { cursor -> + if (cursor == null) { + return emptyList() + } + + while (cursor.moveToNext()) { + val documentId = cursor.getString(0) ?: continue + val displayName = cursor.getString(1) ?: continue + val mimeType = cursor.getString(2) ?: "" + + // dotfiles are not something anyone browses for a document + if (displayName.startsWith(".")) { + continue + } + + val isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + if ( + !isDirectory && + !SupportedDocumentTypes.isSupported(mimeType, displayName) + ) { + continue + } + + children.add(Child(documentId, displayName, mimeType)) + } + } + } catch (e: Exception) { + // a revoked grant, an unmounted sd card or a provider that died - nothing to list + return emptyList() + } + + children.sortWith( + compareBy { !it.isDirectory } + .thenBy(String.CASE_INSENSITIVE_ORDER) { + it.displayName + } + ) + + return children + } + + /** The document id of the folder the user picked, i.e. the root of the tree. */ + fun rootDocumentId(treeUri: Uri): String = DocumentsContract.getTreeDocumentId(treeUri) + + fun documentUri(treeUri: Uri, documentId: String): Uri = + DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) + + /** The name the provider gives the folder itself, falling back to its last path segment. */ + fun displayNameOf(context: Context, treeUri: Uri): String { + val documentUri = documentUri(treeUri, rootDocumentId(treeUri)) + + try { + context.contentResolver + .query( + documentUri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + ) + .use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + cursor.getString(0)?.let { + return it + } + } + } + } catch (e: Exception) { + // fall through to the uri + } + + return treeUri.lastPathSegment ?: treeUri.toString() + } +} diff --git a/app/src/main/java/app/opendocument/droid/background/FileLoader.kt b/app/src/main/java/app/opendocument/droid/background/FileLoader.kt index c9eaf94b6e6f..8785706eefd8 100644 --- a/app/src/main/java/app/opendocument/droid/background/FileLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/FileLoader.kt @@ -200,6 +200,13 @@ abstract class FileLoader(context: Context?, protected val type: LoaderType) { val partTitles: MutableList = LinkedList() val partUris: MutableList = LinkedList() + /** + * Whether the loaded document can be edited and saved again, as the core reports it - see + * [CoreLoader.isDocumentEditable]. False for every other loader, which have nothing to + * edit. + */ + var isEditable: Boolean = false + override fun describeContents(): Int = 0 override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -207,6 +214,7 @@ abstract class FileLoader(context: Context?, protected val type: LoaderType) { parcel.writeParcelable(options, 0) parcel.writeList(partTitles) parcel.writeList(partUris) + ParcelUtil.writeBoolean(parcel, isEditable) } companion object { @@ -225,6 +233,7 @@ abstract class FileLoader(context: Context?, protected val type: LoaderType) { return Result(loaderType, options).also { parcel.readList(it.partTitles, classLoader) parcel.readList(it.partUris, classLoader) + it.isEditable = ParcelUtil.readBoolean(parcel) } } diff --git a/app/src/main/java/app/opendocument/droid/background/FolderTreesUtil.kt b/app/src/main/java/app/opendocument/droid/background/FolderTreesUtil.kt new file mode 100644 index 000000000000..1f1a47e22d62 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/FolderTreesUtil.kt @@ -0,0 +1,100 @@ +package app.opendocument.droid.background + +import android.content.Context +import android.net.Uri +import org.json.JSONObject + +/** + * The folders the user granted access to, in the order they added them. + * + * A json file rather than shared preferences because the order matters and every entry carries a + * cached display name, which a StringSet could not express. [JsonFileStore] does the file half, + * which [RecentDocumentsUtil] shares. + */ +object FolderTreesUtil { + + /** + * How many folders to keep. Each holds a persisted uri permission, and those are capped per + * package - together with [RecentDocumentList.MAX_ENTRIES] this stays far below the limit. + */ + const val MAX_TREES: Int = 8 + + private const val FILENAME = "folder_trees.json" + + private const val KEY_URI = "uri" + private const val KEY_DISPLAY_NAME = "displayName" + + class FolderTree(val uri: Uri, val displayName: String) + + @Synchronized fun getFolderTrees(context: Context): List = read(context) + + /** + * Adds [uri] at the end, or refreshes the name of a folder that is already there. + * + * @return the folders that fell out, so their grants can be released. + */ + @Synchronized + fun addFolderTree(context: Context, uri: Uri, displayName: String): List = + put(context, FolderTree(uri, displayName), index = null) + + /** + * Puts a removed folder back at [index], for undoing a swipe. + * + * Unlike [addFolderTree] this does not move it to the end - an undo is meant to leave the list + * looking exactly as it did. The cap still applies: another folder can have been added while + * the undo was on offer. + * + * @return the folders that fell out, so their grants can be released. + */ + @Synchronized + fun insertFolderTree(context: Context, tree: FolderTree, index: Int): List = + put(context, tree, index) + + private fun put(context: Context, tree: FolderTree, index: Int?): List { + val value = tree.uri.toString() + + val trees = ArrayList(read(context).filter { it.uri.toString() != value }) + trees.add(index?.coerceIn(0, trees.size) ?: trees.size, tree) + + if (trees.size <= MAX_TREES) { + write(context, trees) + + return emptyList() + } + + // oldest first, so the ones that drop off the front are the least recently added + val evicted = trees.subList(0, trees.size - MAX_TREES).toList() + write(context, trees.subList(trees.size - MAX_TREES, trees.size).toList()) + + return evicted + } + + @Synchronized + fun removeFolderTree(context: Context, uri: Uri) { + val value = uri.toString() + + val current = read(context) + val remaining = current.filter { it.uri.toString() != value } + + if (remaining.size != current.size) { + write(context, remaining) + } + } + + private fun read(context: Context): List = + JsonFileStore.read(context, FILENAME) { tree -> + val uri = tree.optString(KEY_URI, "") + + if (uri.isEmpty()) null + else FolderTree(Uri.parse(uri), tree.optString(KEY_DISPLAY_NAME, uri)) + } + + private fun write(context: Context, trees: List) { + JsonFileStore.write(context, FILENAME, trees) { tree -> + JSONObject().apply { + put(KEY_URI, tree.uri.toString()) + put(KEY_DISPLAY_NAME, tree.displayName) + } + } + } +} diff --git a/app/src/main/java/app/opendocument/droid/background/JsonFileStore.kt b/app/src/main/java/app/opendocument/droid/background/JsonFileStore.kt new file mode 100644 index 000000000000..3e862ca7f38d --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/JsonFileStore.kt @@ -0,0 +1,70 @@ +package app.opendocument.droid.background + +import android.content.Context +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import org.json.JSONArray +import org.json.JSONObject + +/** + * A list of objects in an app private json file, read and written whole. + * + * The two lists the landing screen is built from - [RecentDocumentsUtil] and [FolderTreesUtil] - + * used to carry a copy of this each: open the file or treat a missing one as an empty list, walk + * the array, skip what does not parse, write the whole thing back. Only the fields ever differed, + * so the fields are all that is left to them. + * + * Neither list is long enough to be worth a database, and both are only ever replaced entirely. + */ +internal object JsonFileStore { + + /** + * Everything in [filename] that [parse] makes sense of, in the order the file has it. + * + * A missing file is an empty list rather than the [java.io.FileNotFoundException] every caller + * would otherwise have to catch, and so is a truncated one - there is no history to recover, + * and nothing either list holds is worth failing a launch over. + */ + fun read(context: Context, filename: String, parse: (JSONObject) -> T?): List { + val jsonArray = + try { + context.openFileInput(filename).use { input -> + BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).use { reader -> + JSONArray(reader.readText()) + } + } + } catch (e: Exception) { + return emptyList() + } + + val entries = ArrayList(jsonArray.length()) + for (i in 0 until jsonArray.length()) { + val json = jsonArray.optJSONObject(i) ?: continue + + parse(json)?.let { entries.add(it) } + } + + return entries + } + + /** Replaces [filename] with [entries]. */ + fun write( + context: Context, + filename: String, + entries: List, + serialize: (T) -> JSONObject, + ) { + val jsonArray = JSONArray() + for (entry in entries) { + jsonArray.put(serialize(entry)) + } + + context.openFileOutput(filename, Context.MODE_PRIVATE).use { output -> + OutputStreamWriter(output, StandardCharsets.UTF_8).use { writer -> + writer.write(jsonArray.toString()) + } + } + } +} diff --git a/app/src/main/java/app/opendocument/droid/background/LoaderService.kt b/app/src/main/java/app/opendocument/droid/background/LoaderService.kt index f04a8ef8de31..cb2088a971a7 100644 --- a/app/src/main/java/app/opendocument/droid/background/LoaderService.kt +++ b/app/src/main/java/app/opendocument/droid/background/LoaderService.kt @@ -60,7 +60,7 @@ class LoaderService : Service(), FileLoader.FileLoaderListener { crashManager, ) - coreLoader = CoreLoader(this, true) + coreLoader = CoreLoader(this) coreLoader.initialize(this, mainHandler, backgroundHandler, analyticsManager, crashManager) rawLoader = RawLoader(this, coreLoader) diff --git a/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt b/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt index 7ff1c6ac46b1..b1cbdfe72aee 100644 --- a/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt @@ -142,8 +142,12 @@ class OnlineLoader(context: Context?, private val coreLoader: CoreLoader) : } private fun buildViewerUri(options: Options, downloadUrl: String): Uri { - if (coreLoader.isSupported(options)) { - // ODF does not seem to be supported by google docs viewer + // ODF does not seem to be supported by google docs viewer, but pdf is the one thing the + // microsoft viewer will not take - and the core opens both, so what it supports no longer + // tells the two apart on its own + val isPdf = options.fileType?.startsWith("application/pdf") == true + + if (coreLoader.isSupported(options) && !isPdf) { return Uri.parse(MICROSOFT_VIEWER_URL + downloadUrl) } diff --git a/app/src/main/java/app/opendocument/droid/background/PersistedUriPermissions.kt b/app/src/main/java/app/opendocument/droid/background/PersistedUriPermissions.kt index 13561deaffbc..c353908be7f1 100644 --- a/app/src/main/java/app/opendocument/droid/background/PersistedUriPermissions.kt +++ b/app/src/main/java/app/opendocument/droid/background/PersistedUriPermissions.kt @@ -2,6 +2,7 @@ package app.opendocument.droid.background import android.content.Context import android.content.Intent +import android.content.UriPermission import android.net.Uri /** @@ -17,16 +18,17 @@ object PersistedUriPermissions { private const val READ_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION /** - * The grants taken during this process that the recent list does not name yet. + * The grants taken during this process that nothing on disk names yet. * * Taking a grant and writing down what needs it are not one step: a document only reaches the - * list once [MetadataLoader] has read it, which is long after [takeRead] ran - `loadUri` merely - * queues the load. A [prune] in that window would enumerate the fresh grant, find nothing - * referring to it and hand it straight back, leaving the entry that is about to be written - * unreadable on the next launch - the very failure releasing on close used to cause. + * recent list once [MetadataLoader] has read it, which is long after [takeRead] ran - `loadUri` + * merely queues the load - and a folder only reaches the tree list once the landing view + * model's executor gets to it. A [prune] in either window would enumerate the fresh grant, find + * nothing referring to it and hand it straight back, leaving the entry that is about to be + * written unreadable on the next launch - the very failure releasing on close used to cause. * - * Empty on a fresh process, so a grant whose document never made it onto the list is reclaimed - * on the next launch rather than leaking for good. + * Empty on a fresh process, so a grant whose document never made it onto a list is reclaimed on + * the next launch rather than leaking for good. */ private val pendingGrants = HashSet() @@ -83,9 +85,9 @@ object PersistedUriPermissions { * Does file and binder work, so it must not run on the main thread. */ fun prune(context: Context) { - // the three reads are in this order on purpose. a grant taken while this runs is either - // missing from the snapshot, or still pending when the pending set is read - which happens - // after the list that would have settled it, so it cannot fall through both + // the reads are in this order on purpose. a grant taken while this runs is either missing + // from the snapshot, or still pending when the pending set is read - which happens after + // the lists that would have settled it, so it cannot fall through both val held = context.contentResolver.persistedUriPermissions val keep = HashSet() @@ -93,32 +95,52 @@ object PersistedUriPermissions { keep.add(entry.uri) } + val trees = FolderTreesUtil.getFolderTrees(context).map { it.uri.toString() } + keep.addAll(trees) + keep.addAll(settlePending(keep)) for (permission in held) { - val uri = permission.uri.toString() - if (uri in keep) { - continue + if (!isReferenced(permission.uri.toString(), keep, trees)) { + release(context, permission) } + } + } - var flags = 0 - if (permission.isReadPermission) { - flags = flags or READ_FLAG - } - if (permission.isWritePermission) { - flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - } + /** + * Whether [uri] is still spoken for, either by name or by sitting below a granted tree. + * + * [prune]'s whole decision, and free of android imports, so a plain jvm test can cover it. + */ + internal fun isReferenced(uri: String, keep: Set, trees: List): Boolean { + if (uri in keep) { + return true + } - try { - // releasing with flags we do not hold throws, hence taking them off the grant - context.contentResolver.releasePersistableUriPermission(permission.uri, flags) - } catch (e: Exception) { - // nothing to do about it, and it will be retried on the next launch - } + // a document below a granted tree is reachable through that grant, and its uri spells + // the tree out: content://authority/tree//document/. so coverage + // can be recognised from the string, without recording where a document came from. + return trees.any { uri.startsWith("$it/") } + } + + private fun release(context: Context, permission: UriPermission) { + var flags = 0 + if (permission.isReadPermission) { + flags = flags or READ_FLAG + } + if (permission.isWritePermission) { + flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + + try { + // releasing with flags we do not hold throws, hence taking them off the grant + context.contentResolver.releasePersistableUriPermission(permission.uri, flags) + } catch (e: Exception) { + // nothing to do about it, and it will be retried on the next launch } } - /** Remembers a grant until the recent list names it. */ + /** Remembers a grant until one of the stored lists names it. */ @Synchronized internal fun markPending(uri: String) { pendingGrants.add(uri) @@ -126,7 +148,7 @@ object PersistedUriPermissions { /** * Drops the pending grants [keep] has caught up with - those are reclaimable the ordinary way - * from now on - and hands back the ones whose document is still on its way onto the list. + * from now on - and hands back the ones that are still in flight. */ @Synchronized internal fun settlePending(keep: Set): Set { diff --git a/app/src/main/java/app/opendocument/droid/background/PrintingManager.kt b/app/src/main/java/app/opendocument/droid/background/PrintingManager.kt index f1cf0bee63ba..dceb33dff264 100644 --- a/app/src/main/java/app/opendocument/droid/background/PrintingManager.kt +++ b/app/src/main/java/app/opendocument/droid/background/PrintingManager.kt @@ -38,7 +38,13 @@ class PrintingManager { object : Runnable { override fun run() { if (!printJob.isCompleted && !activity.isFinishing && !activity.isDestroyed) { - SnackbarHelper.show(activity, R.string.crouton_printing, null, false, false) + SnackbarHelper.show( + activity, + R.string.crouton_printing, + null, + isIndefinite = false, + isError = false, + ) backgroundHandler.postDelayed(this, 1000) } diff --git a/app/src/main/java/app/opendocument/droid/background/RecentDocumentList.kt b/app/src/main/java/app/opendocument/droid/background/RecentDocumentList.kt index e62367db4d98..95913ce48590 100644 --- a/app/src/main/java/app/opendocument/droid/background/RecentDocumentList.kt +++ b/app/src/main/java/app/opendocument/droid/background/RecentDocumentList.kt @@ -29,8 +29,8 @@ object RecentDocumentList { } /** - * The list after an [add], plus whatever fell off the end of it. The evicted entries are handed - * back so their persisted uri permissions can be released again. + * The list after an [add] or an [insert], plus whatever fell off the end of it. The evicted + * entries are handed back so their persisted uri permissions can be released again. */ class Update internal constructor(val entries: List, val evicted: List) @@ -43,13 +43,36 @@ object RecentDocumentList { entries.add(entry) current.filterTo(entries) { it.uri != entry.uri } + return cap(entries, max) + } + + /** Drops every entry for [uri]. Returns [current] unchanged if there is none. */ + fun remove(current: List, uri: String): List = current.filter { it.uri != uri } + + /** + * Puts [entry] back at [index], for undoing a removal. + * + * Unlike [add] this does not move it to the front - the point of an undo is that the list ends + * up looking like it did before. An index past the end lands at the end. + * + * [max] still applies: the list can have filled up again while the undo was on offer - another + * document opened over the top of the snackbar - and an undo is no reason to grow past the cap. + */ + fun insert(current: List, entry: Entry, index: Int, max: Int = MAX_ENTRIES): Update { + val entries = ArrayList(current.size + 1) + current.filterTo(entries) { it.uri != entry.uri } + + entries.add(index.coerceIn(0, entries.size), entry) + + return cap(entries, max) + } + + /** Keeps the first [max] entries, handing the tail back to be released. */ + private fun cap(entries: List, max: Int): Update { if (entries.size <= max) { return Update(entries, emptyList()) } return Update(entries.subList(0, max).toList(), entries.subList(max, entries.size).toList()) } - - /** Drops every entry for [uri]. Returns [current] unchanged if there is none. */ - fun remove(current: List, uri: String): List = current.filter { it.uri != uri } } diff --git a/app/src/main/java/app/opendocument/droid/background/RecentDocumentsUtil.kt b/app/src/main/java/app/opendocument/droid/background/RecentDocumentsUtil.kt index 9c3c96871cf4..fd8509cb9ebf 100644 --- a/app/src/main/java/app/opendocument/droid/background/RecentDocumentsUtil.kt +++ b/app/src/main/java/app/opendocument/droid/background/RecentDocumentsUtil.kt @@ -2,18 +2,14 @@ package app.opendocument.droid.background import android.content.Context import android.net.Uri -import java.io.BufferedReader -import java.io.InputStreamReader -import java.io.OutputStreamWriter -import java.nio.charset.StandardCharsets -import org.json.JSONArray import org.json.JSONObject /** * Stores the recently opened documents in an app private json file. * - * Only the file and json handling lives here - the ordering and capping rules are in - * [RecentDocumentList], which is android free and unit tested. + * Only the fields live here: [JsonFileStore] does the file and json handling, which + * [FolderTreesUtil] shares, and the ordering and capping rules are in [RecentDocumentList], which + * is android free and unit tested. * * Every method is synchronized: the loaders write from [LoaderService]'s background thread while * the landing screen reads from its own executor. @@ -65,6 +61,26 @@ object RecentDocumentsUtil { return update.evicted } + /** + * Puts a removed document back where it was, for undoing a swipe. + * + * @return the entries that fell out of the list, so [PersistedUriPermissions] can release the + * uri permissions they were holding - the list can have filled up again while the undo was + * still on offer. + */ + @Synchronized + fun restoreRecentDocument( + context: Context, + entry: RecentDocumentList.Entry, + index: Int, + ): List { + val update = RecentDocumentList.insert(read(context), entry, index) + + write(context, update.entries) + + return update.evicted + } + /** Drops [uri] from the list. Does nothing if it is not in it. */ @Synchronized fun removeRecentDocument(context: Context, uri: Uri) { @@ -77,54 +93,27 @@ object RecentDocumentsUtil { } private fun read(context: Context): List { - val jsonArray = - try { - context.openFileInput(FILENAME).use { input -> - BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).use { reader -> - JSONArray(reader.readText()) - } - } - } catch (e: Exception) { - // nothing saved yet, or the file got truncated - either way there is no history - return emptyList() - } - - val entries = ArrayList(jsonArray.length()) - for (i in 0 until jsonArray.length()) { - val document = jsonArray.optJSONObject(i) ?: continue - - val filename = document.optString(KEY_FILENAME, "") - val uri = document.optString(KEY_URI, "") - if (filename.isEmpty() || uri.isEmpty()) { - continue + val entries = + JsonFileStore.read(context, FILENAME) { document -> + val filename = document.optString(KEY_FILENAME, "") + val uri = document.optString(KEY_URI, "") + + if (filename.isEmpty() || uri.isEmpty()) null + else + RecentDocumentList.Entry(filename, uri, document.optLong(KEY_LAST_OPENED_AT, 0)) } - entries.add( - RecentDocumentList.Entry(filename, uri, document.optLong(KEY_LAST_OPENED_AT, 0)) - ) - } - // the file stays in the append order older versions wrote it in, so that upgrading does // not need a migration - the list is only turned around here, on the way out - entries.reverse() - - return entries + return entries.reversed() } private fun write(context: Context, entries: List) { - val jsonArray = JSONArray() - for (entry in entries.asReversed()) { - val document = JSONObject() - document.put(KEY_URI, entry.uri) - document.put(KEY_FILENAME, entry.filename) - document.put(KEY_LAST_OPENED_AT, entry.lastOpenedAt) - - jsonArray.put(document) - } - - context.openFileOutput(FILENAME, Context.MODE_PRIVATE).use { output -> - OutputStreamWriter(output, StandardCharsets.UTF_8).use { writer -> - writer.write(jsonArray.toString()) + JsonFileStore.write(context, FILENAME, entries.reversed()) { entry -> + JSONObject().apply { + put(KEY_URI, entry.uri) + put(KEY_FILENAME, entry.filename) + put(KEY_LAST_OPENED_AT, entry.lastOpenedAt) } } } diff --git a/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt b/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt new file mode 100644 index 000000000000..bb07ebc63301 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt @@ -0,0 +1,114 @@ +package app.opendocument.droid.background + +/** + * The one list of what the app claims to open, for both of the places that have to guess. + * + * There used to be two copies of this - one here for the folder browser, one in [CoreLoader] for + * the loader - which is one copy too many for a set that only ever changes as a whole. + * [isRenderedByCore] is what [CoreLoader.isSupported] answers with, [isSupported] adds what + * [RawLoader] shows and the extension fallback on top, and the STRICT_CATCH `activity-alias` in + * AndroidManifest.xml is the third and last declaration, which XML keeps out of reach. + * `SupportedFormatsTest` walks this table and asks the package manager whether the manifest still + * agrees, so the two cannot drift silently any more. + * + * Deliberately free of Android dependencies, and of the core's own tables - odrcore does know + * [app.opendocument.core.Odr.fileTypeByMimetype] and `fileTypeByFileExtension`, but both are native + * calls into libodr_jni, and both only know one canonical mime type per format: no templates, no + * macro enabled variants, no `application/x-vnd.oasis...`, which are exactly the spellings a + * content provider hands out. What the core does decide is everything *after* the file is in the + * cache - [MetadataLoader] runs its libmagic over it, and [CoreLoader.isDocumentEditable] asks the + * opened document itself. + * + * So this is a guess, not the real answer: a folder listing only has a name and whatever mime type + * the provider volunteered, and it has to go on appearances. + */ +object SupportedDocumentTypes { + + /** + * What the core renders itself: opendocument, ooxml, the three legacy binary microsoft formats + * and pdf. odrcore keeps reference html output for every one of them, doc, ppt and xls + * included. + * + * Matching by prefix carries the variants along: the ooxml prefix covers the templates, and the + * macro enabled ones are spelled `vnd.ms-word.document.macroEnabled.12` and so on, which is why + * the legacy powerpoint and excel types appear here without their subtypes and why + * `vnd.ms-word` is listed next to `msword`. + */ + private val CORE_MIME_PREFIXES = + listOf( + // odf, including the master documents and the x- spelling some providers use + "application/vnd.oasis.opendocument", + "application/x-vnd.oasis.opendocument", + // ooxml: word, excel and powerpoint, their templates and their macro variants + "application/vnd.openxmlformats-officedocument", + "application/vnd.ms-word", + // legacy binary microsoft: doc, xls and ppt + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + // not a document, but the core renders it just the same + "application/pdf", + ) + + /** + * What [RawLoader] shows instead - text, csv, images and zip - which the core would take too + * but which get their own player or viewer here. + * + * Narrower than [RawLoader.isSupported], which also takes audio and video: those are worth + * playing once someone hands us one, but not worth offering the app for. + */ + private val RAW_MIME_PREFIXES = listOf("text/plain", "text/csv", "image/", "application/zip") + + /** + * The extensions to fall back on, spelling out the same set once more because providers + * regularly volunteer nothing better than `application/octet-stream`. + * + * Public so `SupportedFormatsTest` can walk it: it holds every one of these against the core's + * own extension table and against the manifest. + */ + val EXTENSIONS: Set = + setOf( + "odt", + "ods", + "odp", + "odg", + "ott", + "ots", + "otp", + "otg", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "pdf", + "txt", + "csv", + "zip", + ) + + /** Whether [CoreLoader] is expected to render this - see [CORE_MIME_PREFIXES]. */ + fun isRenderedByCore(mimeType: String?): Boolean = matches(mimeType, CORE_MIME_PREFIXES) + + /** Whether the folder browser should offer this at all. */ + fun isSupported(mimeType: String?, filename: String?): Boolean { + // a recognised extension has to be able to win on its own, or every octet-stream is lost + val extension = MimeTypeResolver.parseExtension(filename)?.lowercase() + if (extension != null && extension in EXTENSIONS) { + return true + } + + return isRenderedByCore(mimeType) || matches(mimeType, RAW_MIME_PREFIXES) + } + + private fun matches(mimeType: String?, prefixes: List): Boolean { + if (mimeType == null) { + return false + } + + val normalized = mimeType.lowercase() + + return prefixes.any { normalized.startsWith(it) } + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/SnackbarHelper.kt b/app/src/main/java/app/opendocument/droid/ui/SnackbarHelper.kt index 09a1f2efa9bc..aabe859971ea 100644 --- a/app/src/main/java/app/opendocument/droid/ui/SnackbarHelper.kt +++ b/app/src/main/java/app/opendocument/droid/ui/SnackbarHelper.kt @@ -2,6 +2,8 @@ package app.opendocument.droid.ui import android.app.Activity import android.view.View +import com.google.android.material.R +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar object SnackbarHelper { @@ -23,6 +25,25 @@ object SnackbarHelper { ) } + /** Same, with a button that says something other than "OK" - "undo", typically. */ + fun show( + activity: Activity, + resId: Int, + buttonResId: Int, + callback: Runnable?, + isIndefinite: Boolean, + isError: Boolean, + ) { + show( + activity, + activity.getString(buttonResId), + activity.getString(resId), + callback, + isIndefinite, + isError, + ) + } + private fun show( activity: Activity, buttonText: String, @@ -49,7 +70,16 @@ object SnackbarHelper { } if (isError) { - snackbar.view.setBackgroundColor(0xffff4444.toInt()) + val context = snackbar.view.context + snackbar.view.setBackgroundColor( + MaterialColors.getColor(context, R.attr.colorErrorContainer, 0) + ) + snackbar.setTextColor( + MaterialColors.getColor(context, R.attr.colorOnErrorContainer, 0) + ) + snackbar.setActionTextColor( + MaterialColors.getColor(context, R.attr.colorOnErrorContainer, 0) + ) } snackbar.view.setOnClickListener { snackbar.dismiss() } diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt index 5fe7aa31c5bc..267718c4db26 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt @@ -8,16 +8,13 @@ import android.os.Handler import android.os.Looper import android.text.InputType import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog -import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -32,6 +29,7 @@ import app.opendocument.droid.nonfree.AnalyticsManager import app.opendocument.droid.nonfree.CrashManager import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.SnackbarHelper +import app.opendocument.droid.ui.widget.DocumentActions import app.opendocument.droid.ui.widget.PageView import app.opendocument.droid.ui.widget.ProgressDialogFragment import com.google.android.material.tabs.TabLayout @@ -40,7 +38,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException -class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider { +class DocumentFragment : Fragment(), LoaderService.LoaderListener { private lateinit var mainHandler: Handler @@ -56,7 +54,15 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider var pageView: PageView? = null private set - private var menu: Menu? = null + private lateinit var actions: DocumentActions + + /** Folding the actions back up is what back does first, while they are unfolded. */ + private val actionsBackCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + actions.collapse() + } + } private var resultOnStart: FileLoader.Result? = null private var errorOnStart: Throwable? = null @@ -135,11 +141,7 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - requireActivity().addMenuProvider(this, requireActivity()) - - return inflater.inflate(R.layout.fragment_document, container, false) - } + ): View? = inflater.inflate(R.layout.fragment_document, container, false) private fun initializePageView() { pageView?.let { @@ -154,6 +156,11 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider this.pageView = pageView pageView.setDocumentFragment(this) + + // a property of the page, not of the buttons over it - this used to be the last line + // of prepareActions(), which reached every new PageView only because the two are set + // up together. every one of them passes through here + pageView.disableDarkening() } catch (t: Throwable) { // can't call crashlytics yet at this point (onViewCreated not called) @@ -186,6 +193,16 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider analyticsManager = mainActivity.analyticsManager crashManager = mainActivity.crashManager + actions = view.findViewById(R.id.document_actions) + actions.listener = DocumentActions.Listener { action -> + mainActivity.onDocumentAction(action) + } + actions.expandedListener = { expanded -> actionsBackCallback.isEnabled = expanded } + + // on viewLifecycleOwner, so it stacks above the activity's own callback - the dispatcher + // runs the most recently added enabled callback first + mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, actionsBackCallback) + serviceQueue = mainActivity.loaderServiceQueue serviceQueue.addToQueue { service -> service.setListener(this) } @@ -213,6 +230,7 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider prepareLoad(lastResult.loaderType, false) restoreTabs(lastResult) + prepareActions(lastResult) } pageView?.restoreState(savedInstanceState) @@ -244,24 +262,6 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider tabLayout.addOnTabSelectedListener(tabSelectedListener) } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - this.menu = menu - - menu.findItem(R.id.menu_fullscreen).isVisible = true - menu.findItem(R.id.menu_open_with).isVisible = true - menu.findItem(R.id.menu_share).isVisible = true - menu.findItem(R.id.menu_save).isVisible = true - menu.findItem(R.id.menu_print).isVisible = true - - // the other menu items are dynamically enabled based on the loaded document - state.lastResult?.let { prepareMenu(it.loaderType, it.options.fileType) } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - // TODO: handle menu item clicks here. currently done in Activity for historical reasons - return false - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) @@ -345,8 +345,8 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider requireActivity(), R.string.toast_error_save_nofile, null, - true, - true, + isIndefinite = true, + isError = true, ) return @@ -360,7 +360,10 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider } private fun unload() { - toggleDocumentMenu(false) + // guarded like resetTabs below: a load can fail before there is a view to put right + if (::actions.isInitialized) { + actions.setActions(null, emptyList()) + } resetTabs() } @@ -375,56 +378,80 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider state.lastSelectedTab = -1 } - private fun toggleDocumentMenu(enabled: Boolean) { - toggleDocumentMenu(enabled, enabled) - } - - private fun toggleDocumentMenu(enabled: Boolean, editEnabled: Boolean) { - val menu = this.menu - if (menu == null) { - val activity = activity - val pageView = this.pageView - if (activity == null || activity.isFinishing || pageView == null) { - return - } - - // menu is not set when loadUri is called via onStart, retry later - pageView.post { toggleDocumentMenu(enabled, editEnabled) } - - return - } + /** + * Puts the buttons of the loaded document up, in the order they are worth reaching for. + * + * Called for every result rather than from a menu callback, which is what the toolbar version + * relied on: the menu was only rebuilt when something happened to invalidate it, and a document + * that finished loading is not one of those things. + */ + private fun prepareActions(result: FileLoader.Result) { + // whether editing is on offer is the core's answer, not a list of formats kept here: it + // knows which of the documents it renders it can also write back, which is why neither the + // legacy binary formats nor the spreadsheets of issue #442 need naming + val edit = + if (!result.isEditable) null + else + DocumentActions.Action( + DocumentActions.ACTION_EDIT, + R.string.menu_edit, + R.drawable.ic_edit, + ) - menu.findItem(R.id.menu_edit).isVisible = editEnabled + // the order they unfold in, most wanted first + val unfolding = + listOfNotNull( + edit, + DocumentActions.Action( + DocumentActions.ACTION_TTS, + R.string.menu_tts, + R.drawable.ic_volume_up, + ), + DocumentActions.Action( + DocumentActions.ACTION_SHARE, + R.string.menu_share, + R.drawable.ic_share, + ), + DocumentActions.Action( + DocumentActions.ACTION_PRINT, + R.string.menu_cloud_print, + R.drawable.ic_print, + ), + DocumentActions.Action( + DocumentActions.ACTION_OPEN_WITH, + R.string.menu_open_with, + R.drawable.ic_open_in_new, + ), + DocumentActions.Action( + DocumentActions.ACTION_SAVE, + R.string.action_edit_save, + R.drawable.ic_save, + ), + DocumentActions.Action( + DocumentActions.ACTION_FULLSCREEN, + R.string.menu_fullscreen, + R.drawable.ic_fullscreen, + ), + ) - menu.findItem(R.id.menu_search).isVisible = enabled - menu.findItem(R.id.menu_tts).isVisible = enabled + actions.setActions( + DocumentActions.Action( + DocumentActions.ACTION_SEARCH, + R.string.menu_search, + R.drawable.ic_search, + ), + unfolding, + ) } - private fun prepareMenu(loaderType: FileLoader.LoaderType, fileType: String?) { - var isEditEnabled = false - var isDarkModeSupported = true - - if (loaderType == FileLoader.LoaderType.CORE) { - isEditEnabled = true - - // Edit is currently broken for ODS spreadsheets - // See: https://github.com/opendocument-app/OpenDocument.droid/issues/442 - if ( - fileType != null && - fileType.startsWith("application/vnd.oasis.opendocument.spreadsheet") - ) { - isEditEnabled = false - } - - // Edit is not supported for PDF documents - if (fileType != null && fileType.startsWith("application/pdf")) { - isEditEnabled = false - isDarkModeSupported = false - } + /** Takes the buttons away while the document has the screen to itself. */ + fun setActionsVisible(visible: Boolean) { + if (!::actions.isInitialized) { + return } - toggleDocumentMenu(true, isEditEnabled) - pageView?.toggleDarkMode(isDarkModeSupported) + actions.collapse() + actions.visibility = if (visible) View.VISIBLE else View.GONE } private fun requestInAppRating(activity: Activity) { @@ -494,6 +521,8 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider loadData(result.partUris[0].toString()) } + prepareActions(result) + if ( result.loaderType == FileLoader.LoaderType.RAW || result.loaderType == FileLoader.LoaderType.ONLINE @@ -601,7 +630,13 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider override fun onSaveSuccess(outFile: Uri) { state.currentHtmlDiff = null - SnackbarHelper.show(requireActivity(), R.string.toast_edit_status_saved, null, false, false) + SnackbarHelper.show( + requireActivity(), + R.string.toast_edit_status_saved, + null, + isIndefinite = false, + isError = false, + ) loadUri(outFile, true, true) } @@ -609,7 +644,13 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider override fun onSaveError() { state.currentHtmlDiff = null - SnackbarHelper.show(requireActivity(), R.string.toast_error_save_failed, null, true, true) + SnackbarHelper.show( + requireActivity(), + R.string.toast_error_save_failed, + null, + isIndefinite = true, + isError = true, + ) } private fun offerUpload(activity: Activity, options: FileLoader.Options) { @@ -667,8 +708,8 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider activity, description, { doReopen(options, activity, true, false) }, - isIndefinite, - false, + isIndefinite = isIndefinite, + isError = false, ) } 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..486a3eaaf062 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/activity/LandingFragment.kt @@ -0,0 +1,514 @@ +package app.opendocument.droid.ui.activity + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import app.opendocument.droid.R +import app.opendocument.droid.background.DocumentTreeBrowser +import app.opendocument.droid.background.FolderTreesUtil +import app.opendocument.droid.background.PersistedUriPermissions +import app.opendocument.droid.background.RecentDocumentList +import app.opendocument.droid.nonfree.AnalyticsConstants +import app.opendocument.droid.ui.SnackbarHelper +import app.opendocument.droid.ui.widget.LandingAdapter +import app.opendocument.droid.ui.widget.LandingItem +import com.google.android.material.floatingactionbutton.FloatingActionButton + +/** + * The screen the app opens on: the documents the user was last working on, and the folders they + * granted access to. + * + * 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 fab: FloatingActionButton + + private var landingVisible = true + + // the entries behind the recent and folder rows, kept so a swipe can restore the exact one - + // the rows themselves only carry what they need to draw + private var lastDocuments: List = emptyList() + private var lastTrees: List = emptyList() + + /** + * Back goes up a folder before it goes anywhere else. + * + * Registered on viewLifecycleOwner so it stacks above MainActivity's own callback - the + * dispatcher runs the most recently added enabled callback first. + */ + private val folderBackCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (!viewModel.leaveFolder()) { + isEnabled = false + } + } + } + + /** + * ACTION_OPEN_DOCUMENT_TREE. Read only - the app asks for no write access anywhere, which is + * what keeps it free of storage permissions entirely. + */ + private val openTreeLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) { + return@registerForActivityResult + } + + val treeUri = result.data?.data ?: return@registerForActivityResult + + // has to happen before the transient grant on the result intent expires, and through + // PersistedUriPermissions rather than the resolver directly: addFolderTree writes the + // tree down on the view model's executor, so a prune in between would find a grant + // nothing points at yet and hand it straight back + if (!PersistedUriPermissions.takeRead(requireContext(), treeUri)) { + // some providers refuse to hand out a lasting grant + mainActivity.crashManager.log("could not persist the grant for $treeUri") + + SnackbarHelper.show( + requireActivity(), + R.string.landing_add_folder_failed, + null, + isIndefinite = false, + isError = true, + ) + + return@registerForActivityResult + } + + mainActivity.analyticsManager.report("folder_added") + + viewModel.addFolderTree(treeUri) + } + + 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 + + fab = view.findViewById(R.id.landing_open_fab) + fab.setOnClickListener { + mainActivity.analyticsManager.report("fab_open") + + mainActivity.findDocument() + } + + attachSwipeToRemove() + + requireActivity() + .onBackPressedDispatcher + .addCallback(viewLifecycleOwner, folderBackCallback) + + 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, and a + // grant may have been revoked or an sd card pulled - so this is a reload, not a refresh + viewModel.reload() + } + + /** + * 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. The back callback has to be turned + * off too, or a hidden landing screen would keep swallowing back inside a document. + */ + 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 reloads anyway once it gets there + if (!::viewModel.isInitialized) { + return + } + + updateFolderBack() + + if (visible) { + viewModel.reload() + } + } + + /** + * Back goes up a folder only while this screen is the one on show and there is a folder to + * leave + * - which is two facts, and this is the one place that puts them together. + */ + private fun updateFolderBack() { + folderBackCallback.isEnabled = landingVisible && viewModel.isInsideFolder + } + + private fun render(state: LandingViewModel.State) { + updateFolderBack() + + lastDocuments = state.documents + lastTrees = state.trees + + val items = ArrayList() + + val location = state.location + val isEmpty = location == null && state.documents.isEmpty() && state.trees.isEmpty() + + if (location != null) { + renderFolder(items, state, location) + } else { + renderRoot(items, state, isEmpty) + } + + adapter.submitList(items) + } + + /** + * Swiping a row away removes it, with an undo that puts it back at the same place. A long press + * does the same - see [onDocumentRemoveRequested] and [onFolderRemoveRequested]. + * + * Only the two kinds of row the app owns can go: a recently opened document, and a folder the + * user granted. What is *inside* a granted folder is simply there, and the app has no business + * removing anything from disk. + */ + private fun attachSwipeToRemove() { + val callback = + object : + ItemTouchHelper.SimpleCallback( + 0, + ItemTouchHelper.START or ItemTouchHelper.END, + ) { + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = false + + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ): Int = + if (adapter.isRemovable(viewHolder.bindingAdapterPosition)) + super.getSwipeDirs(recyclerView, viewHolder) + else 0 + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + when (val item = adapter.itemAt(viewHolder.bindingAdapterPosition)) { + is LandingItem.Document -> removeWithUndo(item) + is LandingItem.Folder -> removeFolderWithUndo(item) + else -> Unit + } + } + } + + ItemTouchHelper(callback).attachToRecyclerView(list) + } + + private fun removeWithUndo(document: LandingItem.Document) { + val entry = entryFor(document) ?: return + + // where it sits among the documents, which is what restoring it needs - an adapter + // position would count the section header too + val index = lastDocuments.indexOfFirst { it.uri == entry.uri } + + mainActivity.analyticsManager.report("recent_removed") + + viewModel.removeRecentDocument(document.uri) + + // the grant it was holding is deliberately not released here: an undo would need it back, + // and prune() reclaims it on the next launch anyway, which is the whole point of + // reconciling against the stored lists rather than bookkeeping every removal + SnackbarHelper.show( + requireActivity(), + R.string.landing_recent_removed, + R.string.landing_undo, + { viewModel.restoreRecentDocument(entry, index) }, + isIndefinite = false, + isError = false, + ) + } + + /** + * The folder equivalent, and deliberately the same shape: forget it, say so, offer it back. + * + * This used to ask for confirmation instead, on the grounds that a tree is a permission the + * user granted rather than a row in a list. An undo answers that better than a dialog does - it + * costs nothing when the swipe was meant, and the grant is held until the next launch, so there + * is something to put back. + */ + private fun removeFolderWithUndo(folder: LandingItem.Folder) { + val index = lastTrees.indexOfFirst { it.uri == folder.treeUri } + val tree = lastTrees.getOrNull(index) ?: return + + mainActivity.analyticsManager.report("folder_removed") + + viewModel.removeFolderTree(folder.treeUri) + + SnackbarHelper.show( + requireActivity(), + R.string.landing_folder_removed, + R.string.landing_undo, + { viewModel.restoreFolderTree(tree, index) }, + isIndefinite = false, + isError = false, + ) + } + + private fun entryFor(document: LandingItem.Document): RecentDocumentList.Entry? { + val uri = document.uri.toString() + + return lastDocuments.firstOrNull { it.uri == uri } + } + + private fun renderRoot( + items: ArrayList, + state: LandingViewModel.State, + isEmpty: Boolean, + ) { + if (isEmpty) { + // a row of the list rather than a screen of its own: the settings below it are the + // only place the catch-all switch is offered, and a fresh install starts here. it + // carries both actions itself, so the empty folders section stays off the screen. + items.add(LandingItem.Empty()) + } else { + if (state.documents.isNotEmpty()) { + items.add(LandingItem.Header(getString(R.string.landing_section_recent))) + for (document in state.documents) { + items.add( + LandingItem.Document( + document.filename, + Uri.parse(document.uri), + lastOpenedLabel(document.lastOpenedAt), + recent = true, + ) + ) + } + } + + items.add(LandingItem.Header(getString(R.string.landing_section_folders))) + for (tree in state.trees) { + items.add( + LandingItem.Folder( + tree.displayName, + tree.uri, + DocumentTreeBrowser.rootDocumentId(tree.uri), + granted = true, + ) + ) + } + items.add( + LandingItem.Action( + LandingItem.ACTION_ADD_FOLDER, + R.string.landing_add_folder, + R.drawable.ic_add, + ) + ) + } + + items.add(LandingItem.Header(getString(R.string.landing_section_settings))) + items.add( + LandingItem.Setting( + LandingItem.SETTING_CATCH_ALL, + R.string.landing_catch_all_title, + R.string.landing_intro_open_all, + state.catchAllEnabled, + ) + ) + + // asked here rather than carried in the state: what billing knows lives on the activity, + // not in anything the ViewModel reads. it is only ever false in pro or after a purchase, + // both of which are settled long before the first refresh comes back + if (mainActivity.offersAdRemoval()) { + items.add( + LandingItem.Action( + LandingItem.ACTION_REMOVE_ADS, + R.string.menu_remove_ads, + R.drawable.ic_block, + ) + ) + } + } + + /** "2 hours ago", or nothing at all for entries written before this was recorded. */ + private fun lastOpenedLabel(lastOpenedAt: Long): String? { + if (lastOpenedAt <= 0) { + return null + } + + return DateUtils.getRelativeTimeSpanString( + lastOpenedAt, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ) + .toString() + } + + private fun renderFolder( + items: ArrayList, + state: LandingViewModel.State, + location: LandingViewModel.FolderLocation, + ) { + // the only thing that says where the list is. the activity title used to carry it, until + // the toolbar it was drawn in was taken off the screen and nothing was left showing it + items.add(LandingItem.Header(location.displayName)) + items.add( + LandingItem.Action( + LandingItem.ACTION_UP, + R.string.landing_up_one_level, + R.drawable.ic_arrow_upward, + ) + ) + + if (state.children.isEmpty()) { + items.add(LandingItem.Message(R.string.landing_folder_empty)) + + return + } + + for (child in state.children) { + if (child.isDirectory) { + items.add(LandingItem.Folder(child.displayName, location.treeUri, child.documentId)) + } else { + items.add( + LandingItem.Document( + child.displayName, + DocumentTreeBrowser.documentUri(location.treeUri, child.documentId), + ) + ) + } + } + } + + override fun onDocumentClicked(document: LandingItem.Document) { + mainActivity.analyticsManager.report( + AnalyticsConstants.EVENT_SELECT_CONTENT, + AnalyticsConstants.PARAM_CONTENT_TYPE, + if (viewModel.isInsideFolder) "folder" else "recent", + ) + + mainActivity.loadUri(document.uri) + } + + /** + * A long press removes a recent document, exactly as a swipe does. + * + * It used to ask first and offer no undo, which is the wrong way round of the two: an undo the + * user can ignore costs nothing, a dialog costs a tap every time. One gesture, one outcome. + */ + override fun onDocumentRemoveRequested(document: LandingItem.Document) { + // a document inside a folder is not ours to forget - it is simply there + if (!document.recent) { + return + } + + removeWithUndo(document) + } + + override fun onFolderClicked(folder: LandingItem.Folder) { + viewModel.enterFolder(folder.treeUri, folder.documentId, folder.name) + } + + /** A long press removes a granted folder, exactly as a swipe does. */ + override fun onFolderRemoveRequested(folder: LandingItem.Folder) { + // only the folders the user added themselves can be given back; a sub directory is just + // part of the tree above it + if (!folder.granted) { + return + } + + removeFolderWithUndo(folder) + } + + override fun onActionClicked(action: Int) { + when (action) { + LandingItem.ACTION_ADD_FOLDER -> addFolder() + LandingItem.ACTION_UP -> viewModel.leaveFolder() + LandingItem.ACTION_REMOVE_ADS -> { + mainActivity.analyticsManager.report("settings_remove_ads") + + mainActivity.buyAdRemoval() + } + } + } + + private fun addFolder() { + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + + try { + openTreeLauncher.launch(intent) + } catch (e: Exception) { + mainActivity.crashManager.log(e) + + SnackbarHelper.show( + requireActivity(), + R.string.crouton_error_open_app, + null, + isIndefinite = false, + isError = true, + ) + } + } + + override fun onOpenClicked() { + mainActivity.analyticsManager.report("empty_open") + + mainActivity.findDocument() + } + + override fun onSettingChanged(setting: Int, enabled: Boolean) { + when (setting) { + LandingItem.SETTING_CATCH_ALL -> { + 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..f7650223eebe --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/activity/LandingViewModel.kt @@ -0,0 +1,239 @@ +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.DocumentTreeBrowser +import app.opendocument.droid.background.FolderTreesUtil +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, listing a folder talks to its provider, and + * checking whether a document still resolves does too - so all of it happens on [executor] and is + * 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) { + + /** Where in a folder tree the browser currently is. */ + class FolderLocation(val treeUri: Uri, val documentId: String, val displayName: String) + + class State( + val documents: List, + val trees: List, + val catchAllEnabled: Boolean, + /** null while the roots are shown, otherwise the folder that is open. */ + val location: FolderLocation?, + val children: List, + ) + + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + + private val mutableState = MutableLiveData() + val state: LiveData = mutableState + + // not persisted across process death on purpose: coming back into a folder whose grant was + // revoked while the app was away is a pile of edge cases for very little + private val backStack = ArrayList() + private var location: FolderLocation? = null + + val isInsideFolder: Boolean + get() = location != null + + /** Publishes what is on disk. What the screen's own doing - a navigation, an edit - needs. */ + fun refresh() { + publish(dropUnreachable = false) + } + + /** + * 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. + * + * For coming back to the screen, not for moving around inside it: proving a document still + * resolves is a query per entry, up to [RecentDocumentList.MAX_ENTRIES] of them, and nothing + * this screen does to itself can revoke a grant, unmount a card or delete a file. Only being + * away can, so only coming back has to look. + */ + fun reload() { + publish(dropUnreachable = true) + } + + private fun publish(dropUnreachable: Boolean) { + executor.execute { + val context = getApplication() + + val stored = RecentDocumentsUtil.getRecentDocuments(context) + val trees = FolderTreesUtil.getFolderTrees(context) + val catchAll = CatchAllSetting.isEnabled(context) + val here = location + + val children = + if (here == null) emptyList() + else DocumentTreeBrowser.listChildren(context, here.treeUri, here.documentId) + + mutableState.postValue(State(stored, trees, catchAll, here, children)) + + if (!dropUnreachable) { + return@execute + } + + 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, trees, catchAll, here, children)) + } + } + + fun addFolderTree(treeUri: Uri) { + executor.execute { + val context = getApplication() + + val displayName = DocumentTreeBrowser.displayNameOf(context, treeUri) + FolderTreesUtil.addFolderTree(context, treeUri, displayName) + + // unconditionally, not only when the add evicted an older tree: pruning is also what + // settles the pending grant takeRead() marked, and a tree that stays pending is one + // prune() would keep alive after the user removes it again - see settlePending() + PersistedUriPermissions.prune(context) + + refresh() + } + } + + /** + * Forgets a folder without releasing its grant, so an undo can still put it back - the same + * bargain [removeRecentDocument] makes, and for the same reason: the grant cannot be taken + * again without sending the user back to the system picker, and [PersistedUriPermissions.prune] + * reclaims it on the next launch anyway. + */ + fun removeFolderTree(treeUri: Uri) { + executor.execute { + FolderTreesUtil.removeFolderTree(getApplication(), treeUri) + + // the folder that was just dropped may be the one we are standing in + if (location?.treeUri == treeUri) { + backStack.clear() + location = null + } + + refresh() + } + } + + /** Puts a swiped away folder back where it was, grant and cached name included. */ + fun restoreFolderTree(tree: FolderTreesUtil.FolderTree, index: Int) { + executor.execute { + val context = getApplication() + + // putting one back can push another off the end, if a folder was added while the undo + // was still on offer - that one is holding a grant nothing points at any more + if (FolderTreesUtil.insertFolderTree(context, tree, index).isNotEmpty()) { + PersistedUriPermissions.prune(context) + } + + refresh() + } + } + + fun enterFolder(treeUri: Uri, documentId: String, displayName: String) { + location?.let { backStack.add(it) } + location = FolderLocation(treeUri, documentId, displayName) + + refresh() + } + + /** @return whether there was somewhere to go back to. */ + fun leaveFolder(): Boolean { + if (location == null) { + return false + } + + location = backStack.removeLastOrNull() + + refresh() + + return true + } + + fun setCatchAllEnabled(enabled: Boolean) { + CatchAllSetting.setEnabled(getApplication(), enabled) + + refresh() + } + + /** + * Puts a swiped away document back where it was. + * + * The grant it needs is still held: [prune] only runs after a removal that the user has had the + * chance to undo, so nothing has been handed back yet. + */ + fun restoreRecentDocument(entry: RecentDocumentList.Entry, index: Int) { + executor.execute { + val context = getApplication() + + // putting one back can push another off the end, if a document was opened while the + // undo was still on offer - that one is holding a grant nothing points at any more + if (RecentDocumentsUtil.restoreRecentDocument(context, entry, index).isNotEmpty()) { + PersistedUriPermissions.prune(context) + } + + refresh() + } + } + + /** + * Forgets a document without releasing its grant, so an undo can still put it back. + * + * Nothing here releases it later either: [PersistedUriPermissions.prune] reclaims it on the + * next launch, which is exactly what reconciling against the stored lists - rather than + * bookkeeping every removal - is for. + */ + fun removeRecentDocument(uri: Uri) { + executor.execute { + RecentDocumentsUtil.removeRecentDocument(getApplication(), uri) + + 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..154f8663fabf 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,12 +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 import android.os.Bundle @@ -16,23 +12,17 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.view.ActionMode -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.widget.LinearLayout 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,11 +37,11 @@ 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 app.opendocument.droid.ui.widget.DocumentActions import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability -class MainActivity : AppCompatActivity(), MenuProvider { +class MainActivity : AppCompatActivity() { private lateinit var handler: Handler @@ -60,6 +50,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 @@ -89,8 +84,8 @@ class MainActivity : AppCompatActivity(), MenuProvider { } } + // kept because onPause has to stop it; the edit mode needs no such handle private var ttsActionMode: TtsActionModeCallback? = null - private var editActionMode: EditActionModeCallback? = null lateinit var crashManager: CrashManager private set @@ -179,7 +174,13 @@ class MainActivity : AppCompatActivity(), MenuProvider { WindowInsetsCompat.CONSUMED } - title = "" + // nothing lives in the toolbar any more - the landing screen has its own header and the + // document its buttons - so the bar would just be an empty strip of colour. Hiding it + // rather than moving to a .NoActionBar theme keeps it as the host the action modes are + // raised in: appcompat shows the container again for as long as one is up, and takes it + // back down afterwards, so find, tts and edit still get their bar without the app + // having to put one on screen itself. + supportActionBar?.hide() onBackPressedDispatcher.addCallback(this, backCallback) @@ -192,19 +193,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 @@ -255,8 +258,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { analyticsManager.setCurrentScreen(this, "screen_main") } - - addMenuProvider(this, this) } override fun onStart() { @@ -268,6 +269,11 @@ class MainActivity : AppCompatActivity(), MenuProvider { if (documentFragment != null) { landingContainer.visibility = View.GONE documentContainer.visibility = View.VISIBLE + + // a recreated LandingFragment starts out believing it is on screen, and its view model + // survives with it - so one that was inside a folder would go on eating back presses + // behind the document it was recreated underneath + landingFragment?.setLandingVisible(false) } crashManager.log("onStart") @@ -281,60 +287,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) @@ -417,14 +369,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { ) } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_main, menu) - - if (billingManager.hasPurchased()) { - menu.findItem(R.id.menu_remove_ads).isVisible = false - } - } - // The play services availability dialog calls startActivityForResult() itself with a // request code we hand it, so its result cannot be routed through an // ActivityResultLauncher. Everything the app launches on its own goes through the @@ -451,19 +395,21 @@ class MainActivity : AppCompatActivity(), MenuProvider { var documentFragment = this.documentFragment if (documentFragment == null) { - documentFragment = - supportFragmentManager.findFragmentByTag(DOCUMENT_FRAGMENT_TAG) as DocumentFragment? - landingContainer.visibility = View.GONE documentContainer.visibility = View.VISIBLE - if (documentFragment == null) { - documentFragment = DocumentFragment() - supportFragmentManager - .beginTransaction() - .replace(R.id.document_container, documentFragment, DOCUMENT_FRAGMENT_TAG) - .commitNow() - } + landingFragment?.setLandingVisible(false) + + // the manager can still be holding one the field has not been handed yet, e.g. after + // the process was recreated - taking that one keeps whatever it had already loaded + documentFragment = + supportFragmentManager.findFragmentByTag(DOCUMENT_FRAGMENT_TAG) as DocumentFragment? + ?: DocumentFragment().also { + supportFragmentManager + .beginTransaction() + .replace(R.id.document_container, it, DOCUMENT_FRAGMENT_TAG) + .commitNow() + } this.documentFragment = documentFragment } @@ -491,11 +437,16 @@ class MainActivity : AppCompatActivity(), MenuProvider { documentFragment.loadUri(uri, isPersistentUri) } - override fun onMenuItemSelected(item: MenuItem): Boolean { + /** + * A button of the open document was tapped. These used to be the toolbar menu, and the ids are + * now [DocumentActions]' own - the handling stays here, where the action modes and the printing + * manager already live. + */ + fun onDocumentAction(action: Int) { val documentFragment = this.documentFragment - when (item.itemId) { - R.id.menu_search -> { + when (action) { + DocumentActions.ACTION_SEARCH -> { val findActionModeCallback = FindActionModeCallback(this) documentFragment?.pageView?.let { findActionModeCallback.setWebView(it) } startSupportActionMode(findActionModeCallback) @@ -504,42 +455,25 @@ class MainActivity : AppCompatActivity(), MenuProvider { analyticsManager.report(AnalyticsConstants.EVENT_SEARCH) } - R.id.menu_open -> { - findDocument() - - analyticsManager.report("menu_open") - analyticsManager.report( - AnalyticsConstants.EVENT_SELECT_CONTENT, - AnalyticsConstants.PARAM_CONTENT_TYPE, - "choose", - ) - } - - R.id.menu_open_with -> { + DocumentActions.ACTION_OPEN_WITH -> { documentFragment?.openWith(this) analyticsManager.report("menu_open_with") } - R.id.menu_save -> { + DocumentActions.ACTION_SAVE -> { documentFragment?.prepareSave({ requestSave() }, true) analyticsManager.report("menu_save") } - R.id.menu_share -> { + DocumentActions.ACTION_SHARE -> { documentFragment?.share(this) analyticsManager.report("menu_share") } - R.id.menu_remove_ads -> { - analyticsManager.report("menu_remove_ads") - - buyAdRemoval() - } - - R.id.menu_fullscreen -> { + DocumentActions.ACTION_FULLSCREEN -> { if (fullscreen) { analyticsManager.report("menu_fullscreen_leave") @@ -553,8 +487,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE insetsController.hide(WindowInsetsCompat.Type.statusBars()) - supportActionBar?.hide() - // delay offer to wait for fullscreen animation to finish handler.postDelayed( { @@ -569,19 +501,21 @@ class MainActivity : AppCompatActivity(), MenuProvider { } fullscreen = !fullscreen + + updateDocumentActionsVisible() } - R.id.menu_print -> { + DocumentActions.ACTION_PRINT -> { analyticsManager.report("menu_print") documentFragment?.pageView?.let { pageView -> - pageView.toggleDarkMode(false) + pageView.disableDarkening() printingManager.print(this, pageView) } } - R.id.menu_tts -> { + DocumentActions.ACTION_TTS -> { analyticsManager.report("menu_tts") documentFragment?.pageView?.let { pageView -> @@ -592,21 +526,14 @@ class MainActivity : AppCompatActivity(), MenuProvider { } } - R.id.menu_edit -> { + DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") documentFragment?.let { fragment -> - val editActionMode = EditActionModeCallback(this, fragment) - this.editActionMode = editActionMode - - startSupportActionMode(editActionMode) + startSupportActionMode(EditActionModeCallback(this, fragment)) } } - - else -> return super.onOptionsItemSelected(item) } - - return true } private fun offerPurchase() { @@ -623,32 +550,72 @@ class MainActivity : AppCompatActivity(), MenuProvider { buyAdRemoval() }, - true, - false, + isIndefinite = true, + isError = false, ) } - override fun onActionModeFinished(mode: ActionMode?) { - super.onActionModeFinished(mode) + /** + * The buttons of the document are only up while nothing else is using the screen: an action + * mode has taken the toolbar over and brought its own controls, and fullscreen is for reading. + * + * Counted rather than a flag, because the two kinds of action mode overlap - selecting text in + * the page starts a framework one on top of the appcompat one that edit mode is. + */ + private var actionModes = 0 + + private fun updateDocumentActionsVisible() { + documentFragment?.setActionsVisible(actionModes == 0 && !fullscreen) + } + + // the appcompat ones, which is what startSupportActionMode() raises: find, tts and edit + override fun onSupportActionModeStarted(mode: androidx.appcompat.view.ActionMode) { + super.onSupportActionModeStarted(mode) + + actionModes++ + + updateDocumentActionsVisible() + } + + override fun onSupportActionModeFinished(mode: androidx.appcompat.view.ActionMode) { + super.onSupportActionModeFinished(mode) + + actionModes-- + + updateDocumentActionsVisible() - editActionMode = null ttsActionMode = null } - private fun showRecent() { - val transaction = supportFragmentManager.beginTransaction() + // and the framework ones, which is what selecting text in the page raises + override fun onActionModeStarted(mode: ActionMode?) { + super.onActionModeStarted(mode) - val chooserDialog: DialogFragment = RecentDocumentDialogFragment() - chooserDialog.show(transaction, RecentDocumentDialogFragment.FRAGMENT_TAG) + actionModes++ - analyticsManager.report( - AnalyticsConstants.EVENT_SELECT_CONTENT, - AnalyticsConstants.PARAM_CONTENT_TYPE, - "recent", - ) + updateDocumentActionsVisible() } - private fun buyAdRemoval() { + override fun onActionModeFinished(mode: ActionMode?) { + super.onActionModeFinished(mode) + + actionModes-- + + updateDocumentActionsVisible() + } + + /** + * Whether the ad removal is still worth offering: never in pro, where the purchase is implied, + * and not once it has been bought. + * + * The landing screen asks rather than being told, because billing is set up by + * [initializeProprietaryLibraries] - which can run a second time, after the play services + * dialog - and it is not something the ViewModel could read off disk itself. + */ + fun offersAdRemoval(): Boolean = + ::billingManager.isInitialized && !billingManager.hasPurchased() + + fun buyAdRemoval() { analyticsManager.report(AnalyticsConstants.EVENT_ADD_TO_CART) // the play listing id is the applicationId, which stays at.tomtasche.reader.pro @@ -666,20 +633,18 @@ class MainActivity : AppCompatActivity(), MenuProvider { return } - supportActionBar?.show() - WindowCompat.getInsetsController(window, window.decorView) .show(WindowInsetsCompat.Type.statusBars()) fullscreen = false + updateDocumentActionsVisible() + analyticsManager.report("fullscreen_end") } private fun closeDocument() { documentFragment?.let { fragment -> - removeMenuProvider(fragment) - supportFragmentManager.beginTransaction().remove(fragment).commitNow() documentFragment = null @@ -690,6 +655,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") } @@ -700,58 +669,33 @@ class MainActivity : AppCompatActivity(), MenuProvider { intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val packageManager = packageManager - val targetList: List = - packageManager.queryIntentActivities(intent, 0).filter { target -> - target.activityInfo.packageName == packageName || target.activityInfo.exported - } - - val targetNames = - (targetList.map { it.loadLabel(packageManager).toString() } + - getString(R.string.menu_recent)) - .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 = - ComponentName(target.activityInfo.packageName, target.activityInfo.name) - - try { - OpenFileIdling.increment() - - openDocumentLauncher.launch(intent) - } catch (e: Exception) { - OpenFileIdling.decrement() + // straight to the system picker. this used to put an "Open document via:" dialog of our + // own in front of it, listing every app that answers ACTION_OPEN_DOCUMENT - an extra tap + // that duplicated what the picker itself already offers, since it can browse Drive, + // Downloads, a usb stick and every installed file manager on its own. + try { + OpenFileIdling.increment() - crashManager.log(e) + openDocumentLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + OpenFileIdling.decrement() - SnackbarHelper.show( - this, - R.string.crouton_error_open_app, - { findDocument() }, - true, - true, - ) - } + crashManager.log(e) - analyticsManager.report( - AnalyticsConstants.EVENT_SELECT_CONTENT, - AnalyticsConstants.PARAM_CONTENT_TYPE, - target.activityInfo.packageName, + SnackbarHelper.show( + this, + R.string.crouton_error_open_app, + { findDocument() }, + isIndefinite = true, + isError = true, ) - - dialog.dismiss() } - builder.show() + + analyticsManager.report( + AnalyticsConstants.EVENT_SELECT_CONTENT, + AnalyticsConstants.PARAM_CONTENT_TYPE, + "picker", + ) } override fun onPause() { @@ -787,7 +731,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/DocumentActions.kt b/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt new file mode 100644 index 000000000000..7d3572183a31 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt @@ -0,0 +1,196 @@ +package app.opendocument.droid.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import app.opendocument.droid.R +import com.google.android.material.floatingactionbutton.FloatingActionButton + +/** + * What can be done with the open document, as buttons over the bottom right corner of it: one for + * the action worth its own button, and one that unfolds the rest. + * + * This is what the toolbar menu used to be. A document is read with the phone in one hand, and the + * top right corner of a modern screen is the one place a thumb cannot reach - so the actions sit + * where the thumb already is, and the ones that were hidden behind "More options" now say what they + * are. + * + * Material ships no speed dial component (the one it had was never brought over to Material 3), so + * the rows are built here from [R.layout.item_document_action]. + */ +class DocumentActions(context: Context, attributeSet: AttributeSet?) : + FrameLayout(context, attributeSet) { + + /** One button, identified by one of the ACTION_ ids the caller gets back when it is tapped. */ + class Action(val id: Int, @param:StringRes val label: Int, @param:DrawableRes val icon: Int) + + fun interface Listener { + fun onDocumentActionClicked(id: Int) + } + + var listener: Listener? = null + + /** Told whenever the actions unfold or fold back up, so back can close them. */ + var expandedListener: ((Boolean) -> Unit)? = null + + private val scrim: View + private val rowsScroll: ScrollView + private val rows: LinearLayout + private val primaryButton: FloatingActionButton + private val moreButton: FloatingActionButton + + var isExpanded: Boolean = false + private set + + init { + LayoutInflater.from(context).inflate(R.layout.view_document_actions, this, true) + + scrim = findViewById(R.id.document_actions_scrim) + rowsScroll = findViewById(R.id.document_actions_rows_scroll) + rows = findViewById(R.id.document_actions_rows) + primaryButton = findViewById(R.id.document_actions_primary) + moreButton = findViewById(R.id.document_actions_more) + + scrim.setOnClickListener { collapse() } + moreButton.setOnClickListener { if (isExpanded) collapse() else expand() } + + setActions(null, emptyList()) + } + + /** + * What the document can do right now. [primary] gets a button of its own, the rest unfold out + * of the second one, the first of them closest to it - so the order is most wanted first. + * + * Nothing and an empty list take the buttons away entirely, which is what a document that + * failed to load leaves behind. + */ + fun setActions(primary: Action?, unfolding: List) { + collapse() + + if (primary == null) { + primaryButton.visibility = View.GONE + } else { + primaryButton.visibility = View.VISIBLE + primaryButton.setImageResource(primary.icon) + primaryButton.contentDescription = context.getString(primary.label) + primaryButton.setOnClickListener { listener?.onDocumentActionClicked(primary.id) } + } + + rows.removeAllViews() + + // added back to front, because the row added last is the one that ends up at the bottom + // of the column, right above the button they unfold from + for (action in unfolding.asReversed()) { + rows.addView(newRow(action)) + } + + moreButton.visibility = if (unfolding.isEmpty()) View.GONE else View.VISIBLE + } + + private fun newRow(action: Action): View { + val row = LayoutInflater.from(context).inflate(R.layout.item_document_action, rows, false) + + val label: TextView = row.findViewById(R.id.document_action_label) + label.setText(action.label) + + val button: FloatingActionButton = row.findViewById(R.id.document_action_button) + button.setImageResource(action.icon) + button.contentDescription = context.getString(action.label) + + // the button is only the visible half of the row: the label is as much of a target + val click = OnClickListener { + collapse() + + listener?.onDocumentActionClicked(action.id) + } + row.setOnClickListener(click) + button.setOnClickListener(click) + + return row + } + + private fun expand() { + if (isExpanded || rows.childCount == 0) { + return + } + + isExpanded = true + + // a fold that was still fading out would otherwise hide all of this again when it ends + scrim.animate().cancel() + rowsScroll.animate().cancel() + + scrim.alpha = 0f + scrim.visibility = View.VISIBLE + scrim.animate().alpha(1f).setDuration(ANIMATION_MILLIS) + + rowsScroll.alpha = 1f + rowsScroll.visibility = View.VISIBLE + + // the rows hang off the bottom of the scroller, and that is the end the buttons are at + rowsScroll.post { rowsScroll.fullScroll(View.FOCUS_DOWN) } + + val rise = RISE_DP * resources.displayMetrics.density + for (index in 0 until rows.childCount) { + val row = rows.getChildAt(index) + + row.alpha = 0f + row.translationY = rise + row.animate().alpha(1f).translationY(0f).setDuration(ANIMATION_MILLIS) + } + + moreButton.setImageResource(R.drawable.ic_close) + moreButton.contentDescription = context.getString(R.string.document_actions_close) + + expandedListener?.invoke(true) + } + + fun collapse() { + if (!isExpanded) { + // also the state setActions() starts from, so the views have to be put right anyway + scrim.visibility = View.GONE + rowsScroll.visibility = View.GONE + + return + } + + isExpanded = false + + scrim.animate().alpha(0f).setDuration(ANIMATION_MILLIS).withEndAction { + scrim.visibility = View.GONE + } + + rowsScroll.animate().alpha(0f).setDuration(ANIMATION_MILLIS).withEndAction { + rowsScroll.visibility = View.GONE + rowsScroll.alpha = 1f + } + + moreButton.setImageResource(R.drawable.ic_more_vert) + moreButton.contentDescription = context.getString(R.string.document_actions_more) + + expandedListener?.invoke(false) + } + + companion object { + const val ACTION_SEARCH: Int = 1 + const val ACTION_EDIT: Int = 2 + const val ACTION_TTS: Int = 3 + const val ACTION_SHARE: Int = 4 + const val ACTION_PRINT: Int = 5 + const val ACTION_OPEN_WITH: Int = 6 + const val ACTION_SAVE: Int = 7 + const val ACTION_FULLSCREEN: Int = 8 + + private const val ANIMATION_MILLIS = 150L + + /** How far below its place a row starts out, so the column rises as it appears. */ + private const val RISE_DP = 16f + } +} 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..6192ec5f71e4 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/LandingAdapter.kt @@ -0,0 +1,191 @@ +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, the folders the user granted + * access to, and the settings. + */ +class LandingAdapter(private val listener: Listener) : + ListAdapter(DIFF) { + + interface Listener { + + fun onDocumentClicked(document: LandingItem.Document) + + fun onDocumentRemoveRequested(document: LandingItem.Document) + + fun onFolderClicked(folder: LandingItem.Folder) + + fun onFolderRemoveRequested(folder: LandingItem.Folder) + + fun onActionClicked(action: Int) + + fun onOpenClicked() + + fun onSettingChanged(setting: Int, enabled: Boolean) + } + + /** + * Whether the row at [position] can be swiped away: a recently opened document, or a folder the + * user granted us. Neither a document nor a sub directory inside a tree is ours to remove - + * those are simply there, and the row says which it is rather than the subtitle guessing. + */ + fun isRemovable(position: Int): Boolean = + when (val item = itemAt(position)) { + is LandingItem.Document -> item.recent + is LandingItem.Folder -> item.granted + else -> false + } + + fun itemAt(position: Int): LandingItem? = + if (position in 0 until itemCount) getItem(position) else null + + override fun getItemViewType(position: Int): Int = + when (getItem(position)) { + is LandingItem.Header -> TYPE_HEADER + is LandingItem.Document -> TYPE_DOCUMENT + is LandingItem.Folder -> TYPE_FOLDER + is LandingItem.Action -> TYPE_ACTION + is LandingItem.Setting -> TYPE_SETTING + is LandingItem.Message -> TYPE_MESSAGE + is LandingItem.Empty -> TYPE_EMPTY + } + + 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_SETTING -> R.layout.item_landing_switch + TYPE_MESSAGE -> R.layout.item_landing_message + TYPE_EMPTY -> R.layout.item_landing_empty + // documents, folders and actions are all an icon plus a label + 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.text = item.title + + is LandingItem.Message -> holder.title.setText(item.text) + + is LandingItem.Empty -> { + holder.open.setOnClickListener { listener.onOpenClicked() } + holder.addFolder.setOnClickListener { + listener.onActionClicked(LandingItem.ACTION_ADD_FOLDER) + } + } + + is LandingItem.Document -> { + holder.icon.setImageResource(R.drawable.ic_description) + holder.title.text = item.filename + holder.bindSubtitle(item.subtitle) + holder.itemView.setOnClickListener { listener.onDocumentClicked(item) } + holder.itemView.setOnLongClickListener { + listener.onDocumentRemoveRequested(item) + + true + } + } + + is LandingItem.Folder -> { + holder.icon.setImageResource(R.drawable.ic_folder) + holder.title.text = item.name + holder.bindSubtitle(null) + holder.itemView.setOnClickListener { listener.onFolderClicked(item) } + holder.itemView.setOnLongClickListener { + listener.onFolderRemoveRequested(item) + + true + } + } + + is LandingItem.Action -> { + holder.icon.setImageResource(item.icon) + holder.title.setText(item.label) + holder.bindSubtitle(null) + holder.itemView.setOnClickListener { listener.onActionClicked(item.action) } + holder.itemView.setOnLongClickListener(null) + } + + is LandingItem.Setting -> { + holder.title.setText(item.title) + holder.subtitle.setText(item.body) + + // 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.onSettingChanged(item.setting, 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 subtitle: TextView by lazy { view.findViewById(R.id.landing_row_subtitle) } + val switch: MaterialSwitch by lazy { view.findViewById(R.id.landing_row_switch) } + val open: View by lazy { view.findViewById(R.id.landing_empty_open) } + val addFolder: View by lazy { view.findViewById(R.id.landing_empty_add_folder) } + + fun bindSubtitle(text: String?) { + subtitle.text = text + subtitle.visibility = if (text == null) View.GONE else View.VISIBLE + } + } + + private companion object { + const val TYPE_HEADER = 0 + const val TYPE_DOCUMENT = 1 + const val TYPE_FOLDER = 2 + const val TYPE_ACTION = 3 + const val TYPE_SETTING = 4 + const val TYPE_MESSAGE = 5 + const val TYPE_EMPTY = 6 + + 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.subtitle == newItem.subtitle + + oldItem is LandingItem.Folder && newItem is LandingItem.Folder -> + oldItem.name == newItem.name + + oldItem is LandingItem.Setting && newItem is LandingItem.Setting -> + oldItem.checked == newItem.checked + + // headers, messages, actions and the empty state 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..d351130cee76 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/LandingItem.kt @@ -0,0 +1,89 @@ +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 + + /** + * A section title. Takes the text rather than a string resource, because one of them is the + * name of the folder being browsed - which is the only thing on screen that says where the list + * is. + */ + class Header(val title: String) : LandingItem() { + override val id: String = "header:$title" + } + + /** + * [subtitle] is the second line, when there is something worth saying - a last opened time. + * + * [recent] says the row came from the recently opened list, which is the only thing the app may + * forget; a document inside a granted folder is simply there. It is carried explicitly rather + * than read off [subtitle], which an entry stored before the last opened time was recorded does + * not have. + */ + class Document( + val filename: String, + val uri: Uri, + val subtitle: String? = null, + val recent: Boolean = false, + ) : LandingItem() { + override val id: String = "document:$uri" + } + + /** + * [granted] says this is one of the trees the user handed us, which is the only kind the app + * may give back. A sub directory of one is just part of the tree above it. Same distinction + * [Document.recent] draws, and carried the same way. + */ + class Folder( + val name: String, + val treeUri: Uri, + val documentId: String, + val granted: Boolean = false, + ) : LandingItem() { + override val id: String = "folder:$treeUri:$documentId" + } + + /** A tappable row that is not a document, such as "add a folder". */ + class Action(val action: Int, @param:StringRes val label: Int, val icon: Int) : LandingItem() { + override val id: String = "action:$action" + } + + /** A switch under the settings header. */ + class Setting( + val setting: Int, + @param:StringRes val title: Int, + @param:StringRes val body: Int, + val checked: Boolean, + ) : LandingItem() { + override val id: String = "setting:$setting" + } + + /** 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" + } + + /** + * What the screen says while it has nothing to list, offering both ways to fill it. + * + * A row like any other, so that the settings underneath stay on screen - shown instead of the + * list, it would take the catch-all switch with it on a fresh install. + */ + class Empty : LandingItem() { + override val id: String = "empty" + } + + companion object { + const val ACTION_ADD_FOLDER: Int = 1 + const val ACTION_UP: Int = 2 + const val ACTION_REMOVE_ADS: Int = 3 + + const val SETTING_CATCH_ALL: Int = 1 + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt index 990bf6eddb3f..1453e9bc0502 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt @@ -151,20 +151,30 @@ constructor(context: Context, attributeSet: AttributeSet?) : } } + /** + * Keeps the document on the background it was authored against, whatever the app theme is. + * + * The app shell follows night mode, the page does not: inverting a document is a rendering + * decision the reader has not asked for, and it goes badly on the tables, coloured text and + * images a real odt or docx is full of. Note this only became a live question when the theme + * moved to Material3.DayNight - at targetSdk 33 and up the webview only darkens when the app + * theme reports itself as dark, so under the old AppCompat.Light theme none of this ever fired. + * + * There is no parameter to this any more. It used to take an `isDarkModeSupported` that nothing + * in here ever read - both callers reached the same three lines - while [DocumentFragment] went + * to the trouble of working out that a pdf is not worth inverting. A user facing "dark + * documents" setting can grow the argument back when there is something on the other end of it. + */ @Suppress("DEPRECATION") // setForceDarkAllowed and setForceDark are the pre-webkit-1.6 api - fun toggleDarkMode(isDarkEnabled: Boolean) { + fun disableDarkening() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - isForceDarkAllowed = isDarkEnabled + isForceDarkAllowed = false } if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, isDarkEnabled) + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) } else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { - WebSettingsCompat.setForceDark( - settings, - if (isDarkEnabled) WebSettingsCompat.FORCE_DARK_AUTO - else WebSettingsCompat.FORCE_DARK_OFF, - ) + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) } } diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/ProgressDialogFragment.kt b/app/src/main/java/app/opendocument/droid/ui/widget/ProgressDialogFragment.kt index 899d9ddcb098..89356f9c20c2 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/ProgressDialogFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/ProgressDialogFragment.kt @@ -1,42 +1,43 @@ -@file:Suppress("DEPRECATION") - package app.opendocument.droid.ui.widget import android.annotation.SuppressLint import android.app.Dialog -import android.app.ProgressDialog import android.os.Bundle +import android.widget.TextView import androidx.fragment.app.DialogFragment import app.opendocument.droid.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder /** * @JvmOverloads keeps the no-arg constructor the framework needs to re-create the fragment; a * restored dialog reports itself as a load, same as before. + * + * Built on MaterialAlertDialogBuilder rather than the framework ProgressDialog, which resolves + * android:alertDialogTheme and so would have stayed light once the app theme became DayNight. */ @SuppressLint("ValidFragment") class ProgressDialogFragment @JvmOverloads constructor(private val isUpload: Boolean = false) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val progressDialog = ProgressDialog(activity) - val title = if (isUpload) R.string.dialog_uploading_title else R.string.dialog_loading_title - progressDialog.setTitle(getString(title)) - var message = getString(R.string.dialog_generic_loading_message) if (isUpload) { message += " " + getString(R.string.dialog_uploading_message_appendix) } - progressDialog.setMessage(message) - progressDialog.isIndeterminate = true + val view = layoutInflater.inflate(R.layout.dialog_progress, null) + view.findViewById(R.id.progress_message).text = message // known issue that causes infinite progressdialog - progressDialog.setCancelable(true) setCancelable(true) - return progressDialog + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(title) + .setView(view) + .setCancelable(true) + .create() } companion object { 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/bg_document_action_label.xml b/app/src/main/res/drawable/bg_document_action_label.xml new file mode 100644 index 000000000000..4eaa6a035da3 --- /dev/null +++ b/app/src/main/res/drawable/bg_document_action_label.xml @@ -0,0 +1,9 @@ + + + + + + + + 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_arrow_upward.xml b/app/src/main/res/drawable/ic_arrow_upward.xml new file mode 100644 index 000000000000..21a35e76a761 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_upward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_block.xml b/app/src/main/res/drawable/ic_block.xml new file mode 100644 index 000000000000..53979f05ff4c --- /dev/null +++ b/app/src/main/res/drawable/ic_block.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000000..7a52b3b744f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.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_edit.xml b/app/src/main/res/drawable/ic_edit.xml index 6b75b9362148..30319805b22d 100644 --- a/app/src/main/res/drawable/ic_edit.xml +++ b/app/src/main/res/drawable/ic_edit.xml @@ -1,5 +1,5 @@ + + diff --git a/app/src/main/res/drawable/ic_folder_open.xml b/app/src/main/res/drawable/ic_folder_open.xml index b737fc707168..f417c2a43fb6 100644 --- a/app/src/main/res/drawable/ic_folder_open.xml +++ b/app/src/main/res/drawable/ic_folder_open.xml @@ -1,5 +1,5 @@ + + diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 000000000000..1bd20d427424 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml index bceda6ffc616..6a1945225486 100644 --- a/app/src/main/res/drawable/ic_pause.xml +++ b/app/src/main/res/drawable/ic_pause.xml @@ -1,5 +1,5 @@ + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml index 2f61db8e136d..1aec3cd2054a 100644 --- a/app/src/main/res/drawable/ic_save.xml +++ b/app/src/main/res/drawable/ic_save.xml @@ -1,5 +1,5 @@ + + diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml index 28cb195a6fbe..6f69ba5e78c2 100644 --- a/app/src/main/res/drawable/ic_skip_next.xml +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -1,5 +1,5 @@ + + diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml new file mode 100644 index 000000000000..c1674b925d5d --- /dev/null +++ b/app/src/main/res/layout/dialog_progress.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_document.xml b/app/src/main/res/layout/fragment_document.xml index ff854beddbc6..774974211d26 100644 --- a/app/src/main/res/layout/fragment_document.xml +++ b/app/src/main/res/layout/fragment_document.xml @@ -1,20 +1,34 @@ - - - + android:layout_height="match_parent" + android:orientation="vertical"> - + + + - + 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..86eec3767cda --- /dev/null +++ b/app/src/main/res/layout/fragment_landing.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_document_action.xml b/app/src/main/res/layout/item_document_action.xml new file mode 100644 index 000000000000..2cdcae9f12a5 --- /dev/null +++ b/app/src/main/res/layout/item_document_action.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_landing_empty.xml b/app/src/main/res/layout/item_landing_empty.xml new file mode 100644 index 000000000000..bb7eb0841ae8 --- /dev/null +++ b/app/src/main/res/layout/item_landing_empty.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + 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..3749b1da069e --- /dev/null +++ b/app/src/main/res/layout/item_landing_row.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + 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..ddaade227adb --- /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 9027b0daa65b..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" /> diff --git a/app/src/main/res/layout/view_document_actions.xml b/app/src/main/res/layout/view_document_actions.xml new file mode 100644 index 000000000000..429e0cdbc957 --- /dev/null +++ b/app/src/main/res/layout/view_document_actions.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml deleted file mode 100644 index 9c0df3c4e1b0..000000000000 --- a/app/src/main/res/menu/menu_main.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values-ca-rES/strings.xml b/app/src/main/res/values-ca-rES/strings.xml index 8753e4821bee..ae83a9a811f8 100644 --- a/app/src/main/res/values-ca-rES/strings.xml +++ b/app/src/main/res/values-ca-rES/strings.xml @@ -5,14 +5,10 @@ No sembla que sigui un format de fitxer compatible. S\'ha esgotat la memòria del telèfon. Massa imatges o fitxer massa gran. El document està protegit per contrasenya - Documents recents - Obre el document mitjançant: S\'està carregant… S\'està pujant… No s\'ha pogut obrir el document perquè el seu format no és compatible. Voleu pujar-lo temporalment al nostre servidor per tal que us el puguem mostrar de totes maneres? - No s\'ha trobat cap document Cerca - Obre Google Cloud Print Text-a-paraula Anterior diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 580053f4aa1a..7a43fe8ee430 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -5,19 +5,13 @@ No sembla que sigui un format de fitxer compatible. S\'ha esgotat la memòria del telèfon. Massa imatges o fitxer massa gran. El document està protegit per contrasenya - Documents recents - Obre el document mitjançant: S\'està carregant… S\'està pujant… No s\'ha pogut obrir el document perquè el seu format no és compatible. Voleu pujar-lo temporalment al nostre servidor per tal que us el puguem mostrar de totes maneres? - Trieu un fitxer del dispositiu - No s\'ha trobat cap document Cerca - Obre Mode de pantalla completa Google Cloud Print Text-a-paraula - Premeu Enrere per abandonar el mode de pantalla completa Anterior Següent Cerca a la pàgina diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index 29d67bbf9275..b2e8872661d6 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -5,14 +5,10 @@ Pravděpodobně se nejedná o podporovaný formát souboru. Nedostatek paměti v telefonu! Soubor je příliš velký, nebo obsahuje příliš mnoho obrázků. Dokument je chráněn heslem - Nedávné dokumenty - Otevřít dokument pomocí: Probíhá načítání… Probíhá odesílání… Nelze otevřít soubor, protože nepodporujeme jeho formát. Chcete jej zobrazit tak, že jej dočasně nahrajete na náš server? - Nebyly nalezeny žádné dokunety Vyhledat - Otevřít Upravit dokument Google Cloud Print Převést text na řeč diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ec0be74c59e2..98f98d1eeb9c 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -5,20 +5,14 @@ Pravděpodobně se nejedná o podporovaný formát souboru. Nedostatek paměti v telefonu! Soubor je příliš velký, nebo obsahuje příliš mnoho obrázků. Dokument je chráněn heslem - Nedávné dokumenty - Otevřít dokument pomocí: Probíhá načítání… Probíhá odesílání… Nelze otevřít soubor, protože nepodporujeme jeho formát. Chcete jej zobrazit tak, že jej dočasně nahrajete na náš server? - Vyberte soubor ze zažízení - Nebyly nalezeny žádné dokunety Vyhledat - Otevřít Upravit dokument Celá obrazovka Google Cloud Print Převést text na řeč - Pro opuštění režimu celé obrazovky stiskněte tlačítko Zpět Předchozí Následující Najít na stránce diff --git a/app/src/main/res/values-da-rDK/strings.xml b/app/src/main/res/values-da-rDK/strings.xml index 9bbce203bba0..550e6e876f81 100644 --- a/app/src/main/res/values-da-rDK/strings.xml +++ b/app/src/main/res/values-da-rDK/strings.xml @@ -5,15 +5,10 @@ Det ser ikke ud til, at denne filtype kan bruges. Enheden har ikke mere plads i hukommelsen. Filen er enten for stor eller indeholder for mange billeder. Dokumentet er låst med et kodeord - Seneste dokumenter - Åbn dokument med: Indlæser … Uploader … Dokumentet kan desværre ikke åbnes, fordi appen ikke understøtter formatet. Vil du uploade din fil til vores server midlertidigt, så vi kan åbne den alligevel? - Ingen dokumenter fundet - Senest åbnede dokumenter Søg - Åbn Rediger dokument Fjern reklamer Print dokument diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 02ba47a1747f..af1932acb052 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -5,22 +5,15 @@ Det ser ikke ud til, at denne filtype kan bruges. Enheden har ikke mere plads i hukommelsen. Filen er enten for stor eller indeholder for mange billeder. Dokumentet er låst med et kodeord - Seneste dokumenter - Åbn dokument med: Indlæser … Uploader … Dokumentet kan desværre ikke åbnes, fordi appen ikke understøtter formatet. Vil du uploade din fil til vores server midlertidigt, så vi kan åbne den alligevel? - Vælg fil på enheden - Ingen dokumenter fundet - Senest åbnede dokumenter Søg - Åbn Rediger dokument Fjern reklamer Åben fuldskærmsvisning Print dokument Tekst-Til-Tale - Tryk Tilbage for at forlade fuldskærmsvisning Kunne ikke åbne den valgte app. Forrige Næste @@ -34,9 +27,4 @@ Sat på pause. Færdig. Udskriver… - - Fjern reklamer forevigt - Remove ads for a month - Fjern reklamer midlertidigt ved at se en kort video - diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 3471eb898135..91f020fe879a 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -7,17 +7,12 @@ Datei konnte nicht gespeichert werden. Bitte kontaktiere support@opendocument.app Speicher geht zur Neige! Zu viele Bilder oder eine zu große Datei. Dokument ist passwort-geschützt - Kürzlich geöffnete Dokumente - Öffne Dokument mittels: Lädt… Lädt hoch… Bitte warten. Dies kann einige Minuten dauern. Hochgeladene Dateien sind anonym und werden nach 24 Stunden automatisch gelöscht. Dieses Dokument kann nicht geöffnet werden, weil wir dessen Format nicht unterstützen. Willst du es temporär auf unsere Server hochladen, damit wir es trotzdem für dich anzeigen können? Hochgeladene Dateien sind privat und werden automatisch nach 24 Stunden gelöscht. - Keine Dokumente gefunden - Kürzlich geöffnete Dokumente In Dokument suchen - Dokument öffnen Dokument bearbeiten Werbung entfernen Dokument drucken @@ -32,10 +27,6 @@ Vorheriges Speichern Bearbeite dein Dokument und tippe Speichern - Willkommen bei \nOpenDocument Reader - OpenDocument Reader ermöglicht Ihnen, Dokumente, die im OpenDocument Format (.odt, .ods, .odp und .odg) gespeichert sind, zu lesen - wo auch immer Sie sind. - Mit dem OpenDocument Reader können Sie im Handumdrehen Ihre Dokumente lesen und durchsuchen. - Einen letzten Tippfehler kurz vor der großen Präsentation gefunden? Änderungen werden nun auch unterstützt! Diese App registriert sich standardmäßig für alle Dateitypen um Apps wie \"Samsung My Files\" zu unterstützen. Sie können diese Funktion hier deaktivieren, wenn sie Probleme für Sie verursacht. Wird vorgelesen… Wird initialisiert… diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 94d6acabd23a..1419b48022ea 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -7,25 +7,17 @@ Datei konnte nicht gespeichert werden. Bitte kontaktiere support@opendocument.app Speicher geht zur Neige! Zu viele Bilder oder eine zu große Datei. Dokument ist passwort-geschützt - Kürzlich geöffnete Dokumente - Öffne Dokument mittels: Lädt… Lädt hoch… Bitte warten. Dies kann einige Minuten dauern. Hochgeladene Dateien sind anonym und werden nach 24 Stunden automatisch gelöscht. - Werbung für eine kleine Gebühr entfernen: Dieses Dokument kann nicht geöffnet werden, weil wir dessen Format nicht unterstützen. Willst du es temporär auf unsere Server hochladen, damit wir es trotzdem für dich anzeigen können? Hochgeladene Dateien sind privat und werden automatisch nach 24 Stunden gelöscht. - Wähle eine Datei von diesem Gerät - Keine Dokumente gefunden - Kürzlich geöffnete Dokumente In Dokument suchen - Dokument öffnen Dokument bearbeiten Werbung entfernen Vollbildmodus aktivieren Dokument drucken Sprachausgabe - Drücke Zurück um den Vollbild-Modus zu verlassen App konnte nicht geöffnet werden. Vorheriges Nächstes @@ -36,10 +28,6 @@ Vorheriges Speichern Bearbeite dein Dokument und tippe Speichern - Willkommen bei \nOpenDocument Reader - OpenDocument Reader ermöglicht Ihnen, Dokumente, die im OpenDocument Format (.odt, .ods, .odp und .odg) gespeichert sind, zu lesen - wo auch immer Sie sind. - Mit dem OpenDocument Reader können Sie im Handumdrehen Ihre Dokumente lesen und durchsuchen. - Einen letzten Tippfehler kurz vor der großen Präsentation gefunden? Änderungen werden nun auch unterstützt! Diese App registriert sich standardmäßig für alle Dateitypen um Apps wie \"Samsung My Files\" zu unterstützen. Sie können diese Funktion hier deaktivieren, wenn sie Probleme für Sie verursacht. Wird vorgelesen… Wird initialisiert… @@ -49,18 +37,5 @@ Beendet. Dokument gespeichert. Wird gedruckt… - Drucken ist auf Ihrem Gerät nicht möglich. Bitte aktualisieren Sie auf eine neuere Version von Android. - Öffnen und lesen Sie Ihre ODF-Datei unterwegs! - OpenDocument Reader kann Dokumente anzeigen, die im OpenDocument-Format (.odt, .ods, .odp und .odg) gespeichert sind. Diese Dateien werden in der Regel mit LibreOffice oder OpenOffice erstellt. Diese App ermöglicht es solche Dateien auf Ihrem mobilen Gerät zu öffnen, so dass Sie unterwegs gelesen werden können. - Einen Tippfehler gefunden? Änderungen werden jetzt auch unterstützt! - OpenDocument Reader erlaubt nicht nur Dokumente auf Ihrem mobilen Gerät zu lesen, sondern unterstützt auch, sie zu ändern. Tippfehler sind schnell behoben, auch unterwegs! - Lesen Sie Ihre Dokumente aus anderen Apps heraus - OpenDocument Reader unterstützt eine Vielzahl anderer Apps, um Dokumente zu öffnen. Ein Kollege hat eine Präsentation via Gmail gesendet? Klicken Sie auf den Anhang und diese App wird sofort geöffnet! - LOS Brauchen Sie mehr Platz zum Lesen? Entfernen Sie Werbung kostenlos über das Menü. - - Werbung für immer entfernen - Werbung für ein Monat entfernen - Werbung gratis entfernen durch Ansehen eines kurzen Videos - diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index e9f7dfc01583..bfa56730e803 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -6,17 +6,12 @@ ¿No está contento con cómo se muestra el archivo? Abrir en su lugar en otra aplicación. ¡El teléfono se ha quedado sin memoria! Hay demasiadas imágenes o el archivo es demasiado grande. El documento está protegido con contraseña. - Documentos recientes - Abrir el documento con: Cargando… Subiendo… Por favor, espere, esto podría tardar unos minutos. Los archivos cargados son privados y automáticamente eliminados después de 24 horas. No podemos abrir este documento porque el formato no es compatible. ¿Desea subirlo temporalmente a nuestro servidor para que podamos mostrárselo? Los archivos subidos son privados y se eliminan automáticamente después de 24 horas. - No se ha encontrado ningún documento. - Documentos abiertos recientemente Buscar - Abrir Editar Eliminar anuncios Imprimir @@ -31,10 +26,6 @@ Anterior Guardar Edita el documento a continuación y pulsa Guardar. - Le damos la bienvenida a \nOpenDocument Reader - OpenDocument Reader le permite ver documentos almacenados en formato OpenDocument (.odt, .ods, .odp y .odg) dondequiera que se encuentre. - Con OpenDocument Reader puede leer y buscar en los documentos en un abrir y cerrar de ojos. - ¿Ha encontrado una errata que tiene que corregir justo antes de su gran presentación? ¡Ahora también permite modificaciones! Esta aplicación se registra para abrir todos los tipos de archivos de forma predeterminada para que sea compatible con aplicaciones como «Mis archivos» de Samsung. Puede desactivar esta función aquí si le causa problemas. Leyendo... Iniciando TTS... diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 51cd46d16b5b..a747ed382aee 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,29 +4,19 @@ Ha sido imposible encontrar el archivo. ¿Seguro que no se ha eliminado? Parece que este formato de archivo no es compatible. ¿No está contento con cómo se muestra el archivo? Abrir en su lugar en otra aplicación. - No se ha encontrado ninguna aplicación en el dispositivo que soporte este tipo de archivo. ¡El teléfono se ha quedado sin memoria! Hay demasiadas imágenes o el archivo es demasiado grande. El documento está protegido con contraseña. - Se requiere permiso para leer y guardar documentos - Documentos recientes - Abrir el documento con: Cargando… Subiendo… Por favor, espere, esto podría tardar unos minutos. Los archivos cargados son privados y automáticamente eliminados después de 24 horas. - Compra nuevas funciones: No podemos abrir este documento porque el formato no es compatible. ¿Desea subirlo temporalmente a nuestro servidor para que podamos mostrárselo? Los archivos subidos son privados y se eliminan automáticamente después de 24 horas. - Elige el archivo del dispositivo. - No se ha encontrado ningún documento. - Documentos abiertos recientemente Buscar - Abrir Editar Eliminar anuncios Modo a pantalla completa Imprimir Reconocimiento de voz - Pulsa Volver para salir del modo a pantalla completa. Ha sido imposible abrir esta aplicación. Anterior Siguiente @@ -37,10 +27,6 @@ Anterior Guardar Edita el documento a continuación y pulsa Guardar. - Le damos la bienvenida a \nOpenDocument Reader - OpenDocument Reader le permite ver documentos almacenados en formato OpenDocument (.odt, .ods, .odp y .odg) dondequiera que se encuentre. - Con OpenDocument Reader puede leer y buscar en los documentos en un abrir y cerrar de ojos. - ¿Ha encontrado una errata que tiene que corregir justo antes de su gran presentación? ¡Ahora también permite modificaciones! Esta aplicación se registra para abrir todos los tipos de archivos de forma predeterminada para que sea compatible con aplicaciones como «Mis archivos» de Samsung. Puede desactivar esta función aquí si le causa problemas. Leyendo... Iniciando TTS... @@ -49,19 +35,6 @@ En pausa. Acabado. Imprimiendo… - La impresión no está disponible en su dispositivo. Por favor, actualice a una nueva versión de Android. - ¡Abra y lea su archivo ODF desde cualquier lugar! - OpenDocument Reader le permite ver documentos almacenados en formato OpenDocument (.odt, .ods, .odp y .odg). Estos archivos se suelen crear utilizando LibreOffice u OpenOffice. Esta aplicación permite abrir estos archivos también en su dispositivo móvil, para que pueda leerlos desde cualquier lugar. - ¿Encontró un error tipográfico en su documento? ¡Ahora permite hacer modificaciones! - OpenDocument Reader no solo permite leer documentos en su dispositivo móvil, sino que también puede modificarlos. ¡Los errores tipográficos se arreglan en un abrir y cerrar de ojos, incluso en el tren! - Lea sus documentos desde otras aplicaciones - OpenDocument Reader es compatible con una amplia gama de aplicaciones, y puede abrir los documentos creados con ellas. ¿Un compañero ha enviado una presentación a través de Gmail? ¡Haga clic en el archivo adjunto y la aplicación se abrirá de inmediato! - COMENZAR ¿Necesita más espacio para leer? Elimine los anuncios de forma gratuita a través del menú. Abrir usando otra aplicación instalada en tu dispositivo: - - Elimina los anuncios definitivamente - Remove ads for a month - Eliminar los anuncios temporalmente gratis viendo un vídeo corto - diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 294beecbd0ba..6b1a03506473 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -10,17 +10,12 @@ Le fichier n\'a pas pu être enregistré. Veuillez contacter support@opendocument.app Place insuffisante dans la mémoire de l\'appareil ! Il y a trop d\'images ou les fichiers sont trop volumineux. Ce document est protégé par mot de passe - Documents récents - Ouvrir les documents via : Chargement en cours… Téléchargement en cours… Veuillez patienter. Cette action peut prendre quelques minutes. Les fichiers envoyés sont privés et supprimés automatiquement au bout de 24 heures. Impossible d\'ouvrir ce document car son format n\'est pas supporté. Voulez-vous l\'héberger temporairement sur notre serveur pour le visualiser ? Les fichiers téléchargés sont privés et automatiquement supprimés après 24 heures. - Aucun document trouvé - Documents récemment ouverts Rechercher - Ouvrir Ouvrir avec… Partager le document Modifier @@ -38,10 +33,6 @@ Précédent Enregistrer Modifiez votre document ci-dessous et appuyez sur Enregistrer - Bienvenue dans OpenDocument Reader - OpenDocument Reader vous permet de visualiser les documents enregistrés au format OpenDocument (.odt, .ods, .odp et .odg) où que vous soyez. - Grâce à OpenDocument Reader, vous pouvez lire et rechercher dans vos documents facilement et rapidement. - Vous avez trouvé une faute de frappe dans votre présentation ? Il est désormais possible de la corriger ! Par défaut OpenDocument Reader est associé à tous les types de fichiers afin de pouvoir les ouvrir facilement depuis les applications comme \"Samsung My Files\". Si besoin, ce comportement peut être désactivé ici. Lecture… Initialisation du TTS… @@ -55,11 +46,5 @@ Ouvrir avec une autre application installée sur votre appareil : OK - Vos modifications ne sont pas enregistrées - Les enregistrer maintenant ? - Oui - Ignorer les modifications Annuler - Erreur - Retour aux documents diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b5f16153f034..bcfa84ac21c4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -6,31 +6,21 @@ Ce format de fichier ne semble pas être supporté. Le format de ce fichier n\'est pas pris en charge. Essayez de l\'ouvrir avec une autre application. Vous n\'êtes pas satisfaits de l\'affichage du fichier ? Ouvrez-le avec une autre application. - Aucune application sur l\'appareil ne gère ce type de fichier. Aucun dossier pour enregistrer le fichier sélectionné. Le fichier n\'a pas pu être enregistré. Veuillez contacter support@opendocument.app Place insuffisante dans la mémoire de l\'appareil ! Il y a trop d\'images ou les fichiers sont trop volumineux. Ce document est protégé par mot de passe - Autorisation requise pour lire et enregistrer les documents - Documents récents - Ouvrir les documents via : Chargement en cours… Téléchargement en cours… Veuillez patienter. Cette action peut prendre quelques minutes. Les fichiers envoyés sont privés et supprimés automatiquement au bout de 24 heures. - Acheter de nouvelles fonctions : Impossible d\'ouvrir ce document car son format n\'est pas supporté. Voulez-vous l\'héberger temporairement sur notre serveur pour le visualiser ? Les fichiers téléchargés sont privés et automatiquement supprimés après 24 heures. - Fichiers de l\'appareil - Aucun document trouvé - Documents récemment ouverts Rechercher - Ouvrir Modifier Supprimer les publicités Mode plein écran Imprimer Synthèse vocale - Appuyez sur précédent pour quitter le mode plein écran Impossible d\'ouvrir cette appli. Précédent Suivant @@ -40,12 +30,7 @@ Suivant Précédent Enregistrer - Aide Modifiez votre document ci-dessous et appuyez sur Enregistrer - Bienvenue dans OpenDocument Reader - OpenDocument Reader vous permet de visualiser les documents enregistrés au format OpenDocument (.odt, .ods, .odp et .odg) où que vous soyez. - Grâce à OpenDocument Reader, vous pouvez lire et rechercher dans vos documents facilement et rapidement. - Vous avez trouvé une faute de frappe dans votre présentation ? Il est désormais possible de la corriger ! Par défaut OpenDocument Reader est associé à tous les types de fichiers afin de pouvoir les ouvrir facilement depuis les applications comme \"Samsung My Files\". Si besoin, ce comportement peut être désactivé ici. Lecture… Initialisation du TTS… @@ -55,19 +40,6 @@ Terminé. Document enregistré. Impression… - L’impression n’est pas disponible sur votre appareil. Veuillez mettre à niveau Android vers une version plus récente. - Ouvrez et lisez votre fichier ODF n’importe où ! - OpenDocument Reader vous permet de visualiser, sur votre appareil mobile, les documents enregistrés au format OpenDocument (.odt, .ods, .odp et .odg). Ces fichiers sont généralement créés avec LibreOffice ou OpenOffice. - Vous avez trouvé une faute de frappe dans votre document ? Il est désormais possible de la corriger ! - OpenDocument Reader permet non seulement de lire des documents mais aussi de les modifier sur votre appareil mobile. Vous pouvez corriger les fautes de frappe y compris dans le train ! - Lisez vos documents depuis d’autres applications - OpenDocument Reader prend en charge un grand nombre d’autres applications depuis lesquelles il est possible d’ouvrir des documents. Un collègue a envoyé une présentation via Gmail ? Cliquez sur la pièce jointe et cette application s’ouvrira instantanément ! - DÉMARRER Besoin d’espace pour mieux lire ? Supprimez les publicités gratuitement depuis le menu. Ouvrir avec une autre application installée sur votre appareil : - - Supprimer les pubs définitivement - Remove ads for a month - Supprimer temporairement et gratuitement les pubs en regardant une courte vidéo - diff --git a/app/src/main/res/values-ga-rIE/strings.xml b/app/src/main/res/values-ga-rIE/strings.xml index 411a71bb13f0..e2674f2ab2e0 100644 --- a/app/src/main/res/values-ga-rIE/strings.xml +++ b/app/src/main/res/values-ga-rIE/strings.xml @@ -9,17 +9,12 @@ Níorbh fhéidir an comhad a chur i dtaisce. Déan teagmháil le support@opendocument.app Níl cuimhne fágtha sa ghuthán! Tá an iomarca grianghraif ann nó tá an comhad rómhór. Tá an cháipéis faoi chosaint focal faire - Comhaid le déanaí - Oscail an cháipéis trí: Ag luchtú… Á uasluchtú… Fan. B\'fhéidir go dtógfaidh sé seo roinnt nóiméid. Bíonn comhaid uasluchtaithe príobháideach agus scriostar iad tar éis 24 uair an chloig. Ní thig linn an cháipéis seo a oscailt mar ní thugtar tacaíocht don chineál comhad sin. An mian leat í a uasluchtú go sealadach chuig ár bhfreastalaí ionas gur féidir linn í a thaispeáint duit? - Níor aimsíodh cáipéis ar bith - Cáipéisí a osclaíodh le déanaí Cuardaigh - Oscail Oscail le... Comhroinn an cháipéis Cuir an cháipéis in eagar @@ -36,10 +31,6 @@ Roimhe Taisc Cuir do cháipéis in eagar thíos agus brúigh ar Taisc - Fáilte go \nOpenDocument Reader - Le OpenDocument Reader, tá tú in ann breathnú ar cháipéisí atá i bhformáid OpenDocument (.odt, .ods, .odp, agus .odg) pé áit ina bhfuil tú. - Le hOpenDocument Reader, is féidir leat do chuid cáipéisí a léamh agus a chuardach go héasca. - Ar aimsigh tú aon bhotún amháin eile atá le ceartú sula ndéanann tú do chur i láthair? Anois is féidir athruithe a dhéanamh freisin! Mar réamhshocrú, cláraíonn an feidhmchláirín seo gach cineál comhad mar oscailte chun tacú le feidhmchláiríní amhail \"Samsung My Files\". Tig leat an ghné sin a chur as anseo má chruthaíonn sí fadhbanna duit. Ag léamh… Ag tosú téacs go teanga… @@ -53,11 +44,5 @@ Oscail ag baint feidhm as feidhmchláirín eile atá suiteáilte ar do ghaireas: Tá go maith - Tá athruithe ann nár cuireadh i dtaisce - Cuir i dtaisce anois iad? - Cuir - Cuileáil na hathruithe Cealaigh - Earráid - Ar ais chuig na cáipéisí diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 10e5d92e041e..243fdd638921 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -4,29 +4,19 @@ Níorbh fhéidir an comhad a fháil. B\'fhéidir nach ann dó a thuilleadh? Tá an dealramh air nach dtugtar tacaíocht den chineál comhad sin. An é nach bhfuil tú sásta leis an mbealach ina thaispeántar an comhad? Oscail le feidhmchlár eile é ina ionad sin. - Níor aimsíodh aon fheidhmchlár ar an ngléas a thugann tacaíocht don chineál comhad sin. Níl cuimhne fágtha sa ghuthán! Tá an iomarca grianghraif ann nó tá an comhad rómhór. Tá an cháipéis faoi chosaint focal faire - Tá cead de dhíth chun cáipéisí a léamh agus chun iad a chur i dtaisce - Comhaid le déanaí - Oscail an cháipéis trí: Ag luchtú… Á uasluchtú… Fan. B\'fhéidir go dtógfaidh sé seo roinnt nóiméid. Bíonn comhaid uasluchtaithe príobháideach agus scriostar iad tar éis 24 uair an chloig. - Bain fógraí le táille bheag: Ní thig linn an cháipéis seo a oscailt mar ní thugtar tacaíocht don chineál comhad sin. An mian leat í a uasluchtú go sealadach chuig ár bhfreastalaí ionas gur féidir linn í a thaispeáint duit? - Roghnaigh comhad ón ngléas - Níor aimsíodh cáipéis ar bith - Cáipéisí a osclaíodh le déanaí Cuardaigh - Oscail Cuir an cháipéis in eagar Bain na fógraí Lánscáileán Clóbhuail an cháipéis Téacs-Go-Glór - Brúigh Siar chun an lánscáileán a fhágáil Níorbh fhéidir an feidhmchláirín roghnaithe a oscailt. Roimhe Ar aghaidh @@ -37,10 +27,6 @@ Roimhe Taisc Cuir do cháipéis in eagar thíos agus brúigh ar Taisc - Fáilte go \nOpenDocument Reader - Le OpenDocument Reader, tá tú in ann breathnú ar cháipéisí atá i bhformáid OpenDocument (.odt, .ods, .odp, agus .odg) pé áit ina bhfuil tú. - Le hOpenDocument Reader, is féidir leat do chuid cáipéisí a léamh agus a chuardach go héasca. - Ar aimsigh tú aon bhotún amháin eile atá le ceartú sula ndéanann tú do chur i láthair? Anois is féidir athruithe a dhéanamh freisin! Mar réamhshocrú, cláraíonn an feidhmchláirín seo gach cineál comhad mar oscailte chun tacú le feidhmchláiríní amhail \"Samsung My Files\". Tig leat an ghné sin a chur as anseo má chruthaíonn sí fadhbanna duit. Ag léamh… Ag tosú TTS… @@ -49,19 +35,6 @@ Curtha ar sos. Críochnaithe. Ag clóbhualadh… - Ní féidir clóbhualadh ó do ghaireas. Uasghrádaigh chuig leagan níos nuaí de Android. - Oscail agus léigh do chomhad ODF agus tú ar shiúl! - Le OpenDocument Reader, ligtear duit breathnú ar cháipéisí atá i bhformáid OpenDocument (.odt, .ods, .odp, agus .odg). De ghnáth, cruthaítear na cáipéisí sin le LibreOffice nó le OpenOffice. Leis an bhfeidhmchláirín seo, ligtear duit na cineálacha comhaid sin a oscailt ar do ghaireas soghluaiste freisin, ionas gur féidir leat iad a léamh agus tú ar shiúl. - Ar aimsigh tú botún i do cháipéis? Anois is féidir athruithe a dhéanamh! - Ní hamháin go ligtear duit le OpenDocument Reader cáipéisí a léamh ar do ghaireas soghluaiste, ach is féidir iad a athrú freisin. Is féidir botúin a cheartú go pras, fiú más ar thraein atá tú! - Léigh do chuid cáipéisí ó laistigh d\'fheidhmchláiríní eile - Le OpenDocument Reader, tugtar tacaíocht do réimse mór feidhmchláiríní eile inar féidir cáipéisí a oscailt iontu. Ar sheol comhghleacaí leat cur i láthair chugat trí Gmail? Brúigh ar an gceangaltán agus osclóidh an feidhmchláirín seo láithreach bonn! - TOSAIGH An bhfuil a thuilleadh slí de dhíth ort? Bain fógraí saor in aisce tríd an roghchlár. Oscail ag baint feidhm as feidhmchláirín eile atá suiteáilte ar do ghaireas: - - Bain na fógraí go deo - Remove ads for a month - Bain fógraí go sealadach saor in aisce trí bhreathnú ar fhíseán gearr - diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index 32a0a7dbe213..5dcc546ebee8 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -9,8 +9,6 @@ फ़ाइल save नहीं हो पाया। कृपया support@opendocument.app पर संपर्क करें फ़ोन मे मेमोरी नही है। फ़ोन में बहुत अधिक चित्र हैं, या फ़ाइल बहुत बड़ी है। यह फ़ाइल पासवर्ड द्वारा सुरक्षित है। - ताज़ा दस्तावेज़ - दस्तावेज़ खोलें via: लोड हो रहा है… अपलोड हो रहा है... कृपया प्रतीक्षा करें, इसमें कुछ सेकंड लग सकते हैं diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 9c673c887683..9cf408758e42 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -9,17 +9,12 @@ Impossibile salvare il file. Si prega di contattare support@opendocument.app Memoria del telefono esaurita! Troppe immagini o file troppo grandi. Il documento è protetto da password. - Documenti recenti - Apri documento tramite: Caricamento in corso… Caricamento... Si prega di attendere. Questo potrebbe richiedere alcuni minuti. I file caricati sono privati e automaticamente cancellati dopo 24 ore. Non siamo in grado di aprire questo documento, perché non supportiamo il suo formato. Vuoi caricarlo temporaneamente sul nostro server, in modo da poter visualizzarlo comunque? I file caricati sono privati e cancellati automaticamente dopo 24 ore. - Nessun documento trovato - Documenti aperti di recente Cerca nel documento - Apri documento Apri con... Condividi documento Modifica documento @@ -36,10 +31,6 @@ Precedente Salva Modifica il tuo documento qui sotto e premi Salva - Benvenuto in \nOpenDocument Reader - OpenDocument Reader ti consente di visualizzare i documenti che sono archiviati in formato OpenDocument (.odt, .ods, .odp e .odg) ovunque tu sia. - Con OpenDocument Reader puoi leggere e cercare tra i tuoi documenti in un attimo. - Trovato un ultimo errore di battitura da correggere prima della tua grande presentazione? Ora anche le modifiche sono supportate! Questa app si registra per aprire tutti i tipi di file per impostazione predefinita, per supportare app come \"Samsung My Files\". Puoi disattivare questa funzione qui, se causa problemi. Lettura in corso... Inizializzazione del TTS ... @@ -53,11 +44,5 @@ Apri usando un\'altra app installata sul tuo dispositivo: Ok - Ci sono modifiche non salvate - Salvare ora le modifiche? - - Scarta modifiche Annulla - Errore - Torna ai documenti diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 53f38a5b071c..e628c6190897 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -5,23 +5,15 @@ Questo non sembra essere un formato di file supportato. Memoria del telefono esaurita! Troppe immagini o file troppo grandi. Il documento è protetto da password. - Documenti recenti - Apri documento tramite: Caricamento in corso… Caricamento... - Rimuovere gli annunci pubblicitari per una piccola somma: Non siamo in grado di aprire questo documento, perché non supportiamo il suo formato. Vuoi caricarlo temporaneamente sul nostro server, in modo da poter visualizzarlo comunque? I file caricati sono privati e cancellati automaticamente dopo 24 ore. - Scegli il file dal dispositivo - Nessun documento trovato - Documenti aperti di recente Cerca nel documento - Apri documento Modifica documento Rimuovi pubblicità Apri in modalità schermo intero Stampa il documento Sintesi vocale - Premi Indietro per uscire dalla modalità a schermo intero Impossibile aprire l\'app selezionata. Precedente Successivo @@ -32,10 +24,6 @@ Precedente Salva Modifica il tuo documento qui sotto e premi Salva - Benvenuto in \nOpenDocument Reader - OpenDocument Reader ti consente di visualizzare i documenti che sono archiviati in formato OpenDocument (.odt, .ods, .odp e .odg) ovunque tu sia. - Con OpenDocument Reader puoi leggere e cercare tra i tuoi documenti in un attimo. - Trovato un ultimo errore di battitura da correggere prima della tua grande presentazione? Ora anche le modifiche sono supportate! Questa app si registra per aprire tutti i tipi di file per impostazione predefinita, per supportare app come \"Samsung My Files\". Puoi disattivare questa funzione qui, se causa problemi. Lettura in corso... Inizializzazione del TTS ... @@ -44,18 +32,5 @@ In pausa. Finito. Stampa… - Stampa non disponibile sul tuo dispositivo. Effettua l\'aggiornamento a una versione più recente di Android. - Apri e leggi il tuo file ODF mentre sei in movimento! - OpenDocument Reader ti consente di visualizzare documenti archiviati in formato OpenDocument (.odt, .ods, .odp e .odg). Questi file vengono generalmente creati usando LibreOffice od OpenOffice. Questa app consente di aprire tali file anche sul tuo dispositivo mobile, in modo che tu possa leggerli mentre sei in movimento. - Hai trovato un errore nel tuo documento? Ora la modifica è supportata! - OpenDocument Reader non solo consente di leggere documenti sul tuo dispositivo mobile, ma supporta anche la modifica di questi. Correggere gli errori di battitura è un gioco da ragazzi, anche sul treno! - Leggi i tuoi documenti da altre app - OpenDocument Reader supporta una vasta gamma di altre app da cui aprire documenti. Un collega ha inviato una presentazione tramite Gmail? Fai clic sull\'allegato e questa app si aprirà subito! - AVVIO Hai bisogno di più spazio da leggere? Rimuovi gli annunci gratuitamente tramite il menu. - - Rimuovere gli annunci per sempre - Remove ads for a month - Rimuovere temporaneamente gli annunci gratuitamente guardando un breve video - diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d14a76b1805e..5ddf451e33c6 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -9,17 +9,12 @@ ファイルを保存できませんでした。support@opendoocument.app にお問い合わせください デバイスのメモリ不足です! 写真が多すぎるかファイルが大きすぎます。 ドキュメントがパスワードで保護されています - 最近使用したドキュメント - ドキュメントを次で開く: 読み込んでいます… アップロードしています… しばらくお待ちください。数分かかることがあります。 アップロードされたファイルは保護され、24 時間後に自動的に削除されます。 その形式をサポートしていないため、このドキュメントを開くことができません。一時的に私たちのサーバーにアップロードして、とにかくそれを表示できるようにしますか? アップロードされたファイルはプライベートで、24 時間後に自動的に削除されます。 - ドキュメントが見つかりません - 最近開いたドキュメント ドキュメントの検索 - ドキュメントを開く 開く... ドキュメントを共有 ドキュメントを編集 @@ -36,10 +31,6 @@ 前へ 保存 下の文書の編集して、保存を押してください - OpenDocument リーダー \nへようこそ - OpenDocument リーダーは、どこにいても OpenDocument 形式で格納されているドキュメント (.odt、.ods、.odp、odg) を表示することができます。 - OpenDocument リーダーで、非常に簡単にドキュメントを読んだり、検索することができます。 - 素晴らしいプレゼンテーションに残っている最後の 1 つのタイプミスを見つけましたか? 変更もサポートしました! \"Samsung My Files\" のようなアプリをサポートするために、このアプリはデフォルトですべての種類のファイルを開くように登録します。 問題が発生した場合は、この機能を無効にしてください。 読み込んでいます… テキスト読み上げ機能(TTS)を初期化しています… @@ -53,11 +44,5 @@ お使いのデバイスにインストールされた別のアプリで開く: OK - 保存していない変更があります - 今すぐ保存しますか? - はい - 変更を破棄 キャンセル - エラー - ドキュメントに戻る diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 48f0eab584e7..b4362ecef898 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -5,29 +5,19 @@ これはサポートされているファイル形式ではないようです。 サポートしないファイル形式です。別のアプリで開いてみてください。 ファイルの表示方法に満足できませんか? 代わりに別のアプリで開きます。 - お使いのデバイスで、このファイル形式をサポートするアプリが見つかりませんでした。 デバイスのメモリ不足です! 写真が多すぎるかファイルが大きすぎます。 ドキュメントがパスワードで保護されています - ドキュメントの読み取りと保存を行う権限が必要です - 最近使用したドキュメント - ドキュメントを次で開く: 読み込んでいます… アップロードしています… しばらくお待ちください。数分かかることがあります。 アップロードされたファイルは保護され、24 時間後に自動的に削除されます。 - 小額の支払いで広告を削除します: その形式をサポートしていないため、このドキュメントを開くことができません。一時的に私たちのサーバーにアップロードして、とにかくそれを表示できるようにしますか? アップロードされたファイルはプライベートで、24 時間後に自動的に削除されます。 - デバイスからファイルを選択 - ドキュメントが見つかりません - 最近開いたドキュメント ドキュメントの検索 - ドキュメントを開く ドキュメントを編集 広告を削除する 全画面表示を開く ドキュメントを印刷 テキスト読み上げ - 戻るボタンを押すと全画面モードを終了します 選択したアプリを開くことができません。 前へ 次へ @@ -37,12 +27,7 @@ 次へ 前へ 保存 - ヘルプ 下の文書の編集して、保存を押してください - OpenDocument リーダー \nへようこそ - OpenDocument リーダーは、どこにいても OpenDocument 形式で格納されているドキュメント (.odt、.ods、.odp、odg) を表示することができます。 - OpenDocument リーダーで、非常に簡単にドキュメントを読んだり、検索することができます。 - 素晴らしいプレゼンテーションに残っている最後の 1 つのタイプミスを見つけましたか? 変更もサポートしました! \"Samsung My Files\" のようなアプリをサポートするために、このアプリはデフォルトですべての種類のファイルを開くように登録します。 問題が発生した場合は、この機能を無効にしてください。 読み込んでいます… テキスト読み上げ機能(TTS)を初期化しています… @@ -51,19 +36,6 @@ 一時停止しました。 完了しました。 印刷しています… - お使いのデバイスで印刷は利用できません。Android の新しいバージョンにアップグレードしてください。 - 外出先で ODF ファイルを開いたり読み込みます! - OpenDocument リーダーは OpenDocument 形式 (.odt、.ods、.odp、.odg) で格納されているドキュメントを表示することができます。通常これらのファイルは、LibreOffice または OpenOffice を使用して作成されます。このアプリは、モバイルデバイスでこのようなファイルを開くことができるので、外出先でそれらを読み取ることができます。 - ドキュメントにタイプミスを発見しましたか? 変更をサポートするようになりました! - OpenDocument リーダーは、お使いのモバイルデバイス上でドキュメントを読むことができるだけでなく、変更もサポートしています。タイプミスは、電車の中でもすぐに修正できます! - 他のアプリを使用してドキュメントを読む - OpenDocument リーダーは、ドキュメントを開く他のアプリを幅広くサポートしています。同僚が Gmail でプレゼンテーションを送信しましたか? 添付ファイルをクリックすると、このアプリがすぐに開くことができます! - 開始 読むためのスペースがもっと必要ですか? メニューから無料の広告を削除してください。 お使いのデバイスにインストールされた別のアプリで開く: - - 永久に広告を削除する - Remove ads for a month - 無料の短いビデオを見て、一時的に広告を削除する - diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index cba5cb5deb26..aa4034da1341 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -9,17 +9,12 @@ 파일이 저장되지 않았습니다. support@opendocument.app 으로 문의하세요. 메모리가 부족합니다! 사진이 너무 많거나 파일이 너무 큽니다. 문서가 암호로 잠겨 있습니다. - 최근 문서 - 문서 열기: 로딩 중... 업로드 중... 잠시 기다려 주세요. 업로드한 파일은 비공개이고, 24시간 뒤에 자동 삭제됩니다. 이 포맷을 지원하지 않기에 문서를 열 수 없습니다. 임시로 서버에 업로드해서 여시겠습니까? 업로드한 파일은 비공개이고, 24 시간 뒤에 자동으로 삭제합니다. - 문서를 찾을 수 없습니다. - 최근에 열어 본 문서 문서 검색 - 문서 열기 다른 앱으로 열기... 문서 공유 문서 편집 @@ -37,10 +32,6 @@ 이전 저장 문서를 수정하고, 아래에서 저장을 누르세요. - OpenDocument Reader를\n 오신 것을 환영합니다. - OpenDocument Reader를 사용해서 어디서든 OpenDocument 포맷(.odt, .ods, .odp 및 .odg) 문서를 볼 수 있습니다. - OpenDocument Reader를 사용해서 쉽게 문서를 찾고 열 수 있습니다. - 중요한 프레젠테이션에서 수정할 오타가 하나 남았나요? 이제 수정도 지원합니다! 이 앱은 삼성 \"My Files\"과 같은 앱을 지원하기 위해, 기본으로 모든 파일 포맷을 열게 등록되어 있습니다. 문제가 발생하면 이 기능을 끌 수 있습니다. 읽는 중... TTS 초기화 중... @@ -54,11 +45,5 @@ 기기에 설치된 다른 앱을 사용해서 열기: 확인 - 저장된 내용을 변경하지 않음 - 저장하시겠습니까? - - 변경 취소 취소 - 에러 - 문서로 돌아가기 diff --git a/app/src/main/res/values-large/styles.xml b/app/src/main/res/values-large/styles.xml deleted file mode 100644 index 4c7c41ed9349..000000000000 --- a/app/src/main/res/values-large/styles.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-night/bools.xml b/app/src/main/res/values-night/bools.xml new file mode 100644 index 000000000000..7f78654a8d0c --- /dev/null +++ b/app/src/main/res/values-night/bools.xml @@ -0,0 +1,6 @@ + + + + false + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 000000000000..6cfa605a4571 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,38 @@ + + + + + #86CFFF + #00344C + #004C6D + #C7E7FF + + #B6C9D8 + #21323E + #384955 + #D2E5F5 + + #191C1E + #E2E2E5 + #41484D + #C1C7CE + #1D2022 + + #CDC0E9 + #342B4B + #4B4163 + #E9DDFF + + #E2E2E5 + #2E3133 + #00658F + + #8B9198 + #41484D + + #FFB4AB + #690005 + #93000A + #FFDAD6 + + 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-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index f998a619f7e0..da20a7d0dbf3 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -5,15 +5,10 @@ Ten format pliku nie jest obsługiwany. Brak pamięci telefonu! Zbyt wiele zdjęć lub plik jest za duży. Dokument zabezpieczony jest hasłem - Ostatnie dokumenty - Otwórz dokument za pomocą: Pobieranie… Wysyłanie... Nie możemy otworzyć tego dokumentu, ponieważ nie obsługujemy jego formatu. Czy chcesz tymczasowo przesłać go na nasz serwer, abyśmy mimo wszystko mogli go wyświetlić? Przesłane pliki są prywatne i automatycznie usuwane są po 24 godzinach. - Nie znaleziono żadnego dokumentu - Ostatnio otwarte dokumenty Szukaj w dokumencie - Otwórz dokument Edytuj dokument Usuń reklamy Drukuj dokument @@ -28,10 +23,6 @@ Poprzedni Zapisz Edytuj swój dokument poniżej i naciśnij Zapisz - Witamy w \n przeglądarce OpenDocument Reader - Dzięki czytnikowi OpenDocument Reader możesz w dowolnym miejscu wyświetlać dokumenty zapisane w formacie OpenDocument (.odt, .ods, .odp i .odg). - Za pomocą przeglądarki OpenDocument Reader możesz czytać i błyskawicznie przeszukiwać swoje dokumenty. - Znalazła się jeszcze jedna literówka do usunięcia przed dużą prezentacją? Teraz przeglądarka ta obsługuje również zmiany! Niniejszą aplikację rejestruje się, aby móc domyślnie otwierać wszystkie typy plików w celu obsługi aplikacji takich jak „Samsung My Files”. Tutaj możesz wyłączyć tę funkcję, jeśli powoduje ona problemy. Trwa odczyt... Inicjowanie TTS… diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b1726e68385d..d3f2ac691855 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -5,23 +5,15 @@ Ten format pliku nie jest obsługiwany. Brak pamięci telefonu! Zbyt wiele zdjęć lub plik jest za duży. Dokument zabezpieczony jest hasłem - Ostatnie dokumenty - Otwórz dokument za pomocą: Pobieranie… Wysyłanie... - Usuń reklamy za niewielką opłatą: Nie możemy otworzyć tego dokumentu, ponieważ nie obsługujemy jego formatu. Czy chcesz tymczasowo przesłać go na nasz serwer, abyśmy mimo wszystko mogli go wyświetlić? Przesłane pliki są prywatne i automatycznie usuwane są po 24 godzinach. - Wybierz plik z urządzenia - Nie znaleziono żadnego dokumentu - Ostatnio otwarte dokumenty Szukaj w dokumencie - Otwórz dokument Edytuj dokument Usuń reklamy Otwórz tryb pełnoekranowy Drukuj dokument Przetwarzanie tekstu na mowę - Naciśnij Cofnij, aby wyjść z trybu pełnoekranowego Nie można otworzyć wybranej aplikacji Poprzedni Następny @@ -32,10 +24,6 @@ Poprzedni Zapisz Edytuj swój dokument poniżej i naciśnij Zapisz - Witamy w \n przeglądarce OpenDocument Reader - Dzięki czytnikowi OpenDocument Reader możesz w dowolnym miejscu wyświetlać dokumenty zapisane w formacie OpenDocument (.odt, .ods, .odp i .odg). - Za pomocą przeglądarki OpenDocument Reader możesz czytać i błyskawicznie przeszukiwać swoje dokumenty. - Znalazła się jeszcze jedna literówka do usunięcia przed dużą prezentacją? Teraz przeglądarka ta obsługuje również zmiany! Niniejszą aplikację rejestruje się, aby móc domyślnie otwierać wszystkie typy plików w celu obsługi aplikacji takich jak „Samsung My Files”. Tutaj możesz wyłączyć tę funkcję, jeśli powoduje ona problemy. Trwa odczyt... Inicjowanie TTS… @@ -44,18 +32,5 @@ Wstrzymano. Ukończono. Trwa drukowanie... - W Twoim urządzeniu drukowanie nie jest dostępne. Uaktualnij do nowszej wersji Androida. - Otwórz i czytaj swój plik ODF nawet w podróży! - Przeglądarka OpenDocument Reader umożliwia przeglądanie dokumentów przechowywanych w formacie OpenDocument (.odt, .ods, .odp i .odg). Pliki te są zazwyczaj tworzone przy użyciu programów LibreOffice lub OpenOffice. Ta aplikacja umożliwia również otwieranie tego typu plików na urządzeniu mobilnym, dzięki czemu możesz je czytać również w podróży. - Znajdujesz literówkę w swoim dokumencie? Czytnik obsługuje teraz zmiany! - Przeglądarka OpenDocument Reader nie tylko pozwala czytać dokumenty na urządzeniu mobilnym, ale także obsługuje ich modyfikowanie. Literówki są usuwane natychmiast, nawet w pociągu! - Czytaj swoje dokumenty pochodzące z innych aplikacji - Przeglądarka OpenDocument Reader obsługuje wiele różnych aplikacji, umożliwiając otwieranie w nich dokumentów. Kolega wysłał prezentację za pośrednictwem poczty Gmail? Kliknij załącznik i aplikacja ta natychmiast się otworzy! - ROZPOCZNIJ Potrzebujesz więcej miejsca do czytania? Usuń bezpłatnie reklamy, używając menu. - - Usuń reklamy na trwałe - Remove ads for a month - Usuń reklamy na pewien czas i obejrzyj krótki film - diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1ecf20dcf49f..ad9b9f0602c2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -9,17 +9,12 @@ Arquivo não pôde ser salvo. Por favor, contate support@opendocument.app Telefone sem memória! Muitas fotos ou arquivo muito grande. O documento está protegido por senha - Documentos recentes - Abra o documento através de: Carregando… Fazendo upload… Por favor, aguarde, isso pode levar alguns minutos. Os arquivos enviados são privados e automaticamente excluídos após 24 horas. Não conseguimos abrir este documento, pois não suportamos este formato. Você deseja fazer o upload dele em nosso servidor temporariamente, para que possamos exibi-lo para você? - Nenhum documento encontrado - Documentos abertos recentemente Pesquisa - Abrir Abrir com... Compartilhar documento Editar @@ -36,10 +31,6 @@ Anterior Salvar Edite seu documento abaixo e pressione Salvar - Bem-vindo ao \nOpenDocument Reader - O OpenDocument Reader permite que você veja documentos que são armazenados no formato OpenDocument (.odt, .ods, .odp e .odg) onde quer que você esteja. - Com o OpenDocument Reader, você pode ler e pesquisar seus documentos de forma fácil. - Encontrou um último erro de digitação para corrigir a sua grande apresentação? Agora suporta modificações também! Este app se registra para abrir todos os tipos de arquivos por padrão para apoiar apps como \"Samsung My Files\". Você pode desativar este recurso aqui se ele causar problemas para você. Lendo… Iniciando TTS… diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 2d781ce475fa..2bf6c3b863e4 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -5,15 +5,10 @@ Возможно, этот формат файла не поддерживается. Недостаточно памяти на телефоне! Слишком много изображений, или файл слишком большой. Документ защищён паролем - Последние документы - Открыть документ с помощью: Загрузка… Выгрузка... Мы не можем открыть этот документ, потому что его формат не поддерживается. Хотите загрузить его на наш сервер временно, чтобы его можно было отобразить? Загруженные файлы предназначены только для вашего личного использования и автоматически удаляются через 24 часа. - Документы не найдены - Недавно открытые документы Поиск в документе - Открыть документ Редактировать документ Убрать рекламные объявления Полноэкранный режим @@ -29,10 +24,6 @@ Предыдущий Сохранить Отредактируйте ваш документ ниже и нажмите \"Сохранить\" - Добро пожаловать в \nOpenDocument Reader - OpenDocument Reader позволяет просматривать документы, которые сохранены в формате OpenDocument (.odt, .ods, .odp и .odg), из любого места. - С помощью OpenDocument Reader вы можете легко читать и просматривать свои документы. - В последний момент нашли ошибку, которую нужно исправить перед важной презентацией? Теперь программа поддерживает возможность вносить в документ изменения! Приложение регистрируется для автоматического открытия всех типов файлов, чтобы поддерживать такие приложения, как Samsung My Files. Если эта функция создает вам проблемы, отключите ее здесь. Чтение... Инициализация преобразования текста в речь… diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7d0579b7966a..6b1c23bfa505 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -5,23 +5,15 @@ Возможно, этот формат файла не поддерживается. Недостаточно памяти на телефоне! Слишком много изображений, или файл слишком большой. Документ защищён паролем - Последние документы - Открыть документ с помощью: Загрузка… Выгрузка... - Уберите рекламные объявления за небольшую плату: Мы не можем открыть этот документ, потому что его формат не поддерживается. Хотите загрузить его на наш сервер временно, чтобы его можно было отобразить? Загруженные файлы предназначены только для вашего личного использования и автоматически удаляются через 24 часа. - Выберите файл с устройства - Документы не найдены - Недавно открытые документы Поиск в документе - Открыть документ Редактировать документ Убрать рекламные объявления Открыть в полноэкранном режиме Печать документа Преобразование текста в речь - Нажмите \"Назад\", чтобы выйти из полноэкранного режима Не удалось открыть выбранное приложение. Предыдущий Следующий @@ -32,10 +24,6 @@ Предыдущий Сохранить Отредактируйте ваш документ ниже и нажмите \"Сохранить\" - Добро пожаловать в \nOpenDocument Reader - OpenDocument Reader позволяет просматривать документы, которые сохранены в формате OpenDocument (.odt, .ods, .odp и .odg), из любого места. - С помощью OpenDocument Reader вы можете легко читать и просматривать свои документы. - В последний момент нашли ошибку, которую нужно исправить перед важной презентацией? Теперь программа поддерживает возможность вносить в документ изменения! Приложение регистрируется для автоматического открытия всех типов файлов, чтобы поддерживать такие приложения, как Samsung My Files. Если эта функция создает вам проблемы, отключите ее здесь. Чтение... Инициализация преобразования текста в речь… @@ -44,18 +32,5 @@ Приостановлено. Выполнено. Печать... - Печать на вашем устройстве недоступна. Пожалуйста, обновите Android до новой версии. - Открывайте и читайте файлы ODF прямо на ходу! - OpenDocument Reader позволяет просматривать документы, сохраненные в формате OpenDocument (.odt, .ods, .odp и .odg). Эти файлы обычно создаются в программах LibreOffice или OpenOffice. С помощью приложения вы также можете открывать такие файлы на мобильном устройстве и читать их прямо на ходу. - Нашли ошибку в своем документе? Теперь вы можете внести в него изменения! - OpenDocument Reader позволяет не только читать документы с мобильного устройства, но и поддерживает возможность вносить в них изменения. Исправляйте ошибки легко, даже в поезде! - Читайте ваши документы из других приложений - OpenDocument Reader поддерживает возможность открывать документы из многих других приложений. Коллега отправил вам презентацию через Gmail? Нажмите на вложение, и приложение немедленно откроется! - НАЧАТЬ Мало места для чтения? Уберите рекламу бесплатно с помощью меню. - - Убрать рекламу навсегда - Remove ads for a month - Временно убрать рекламу, посмотрев короткое видео - diff --git a/app/src/main/res/values-sl-rSI/strings.xml b/app/src/main/res/values-sl-rSI/strings.xml index a64a94934643..0082de151ccd 100644 --- a/app/src/main/res/values-sl-rSI/strings.xml +++ b/app/src/main/res/values-sl-rSI/strings.xml @@ -9,17 +9,12 @@ Datoteke ni bilo mogoče shraniti. Prosimo, pišite na support@opendocument.app. Telefonu je zmanjkalo pomnilnika! Preveč slik ali prevelika datoteka. Dokument je zaščiten z geslom - Nedavni dokumenti - Odpri dokument preko: Nalaganje … Pošiljanje … Počakajte, to lahko traja nekaj minut. Poslane datoteke so zasebne in se samodejno izbrišejo po 24-ih urah. Tega dokumenta ne moremo odpreti, ker ne podpiramo njegove oblike. Ali ga želite začasno poslati na naš strežnik, da ga lahko vseeno prikažemo? - Nobenih dokumentov ni bilo najdenih - Nedavni dokumenti Išči - Odpri Odpri s/z … Deli dokument Uredi @@ -36,10 +31,6 @@ Nazaj Shrani Spodaj uredite dokument in pritisnite Shrani - Dobrodošli v \nOpenDocument Reader - OpenDocument Reader povsod omogoča ogled dokumentov, ki so shranjeni v obliki OpenDocument (.odt, .ods, .odp). - S programom OpenDocument Reader lahko berete in hitro iščete po dokumentih. - Ste našli še zadnjo napako v dokumentu, ki jo želite popraviti? Sedaj podpira tudi urejanje! Ta program se registrira, da privzeto odpre vse vrste datotek, da lahko podpira programe, kot je \"Samsung Moje datoteke\". Če vam ta značilnost povzroča težave, jo lahko tukaj izklopite. Branje … Začenjanje TTS-a … @@ -52,11 +43,5 @@ Odpri z drugim nameščenim programom na napravi: V redu - Spremembe niso shranjene! - Shrani jih zdaj? - Da - Zavrzi spremembe Prekliči - Napaka - Nazaj v dokumente diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 5f3aaa3ed701..759bd1acbe7e 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -5,31 +5,21 @@ Videti je, da ta oblika datoteke ni podprta. Nepodprta oblika datoteke. Poizkusite jo odpreti v drugem programu. Niste zadovoljni z načinom prikaza datoteke? Odprite jo v drugem programu. - Na napravi ni bilo najdenega programa, ki podpira to vrsto datotek. Mesto za shranjevanje datoteke ni izbrano. Datoteke ni bilo mogoče shraniti. Prosimo, pišite na support@opendocument.app. Telefonu je zmanjkalo pomnilnika! Preveč slik ali prevelika datoteka. Dokument je zaščiten z geslom - Za branje in shranjevanje dokumentov je zahtevano dovoljenje - Nedavni dokumenti - Odpri dokument preko: Nalaganje … Pošiljanje … Počakajte, to lahko traja nekaj minut. Poslane datoteke so zasebne in se samodejno izbrišejo po 24-ih urah. - Kupite nove značilnosti: Tega dokumenta ne moremo odpreti, ker ne podpiramo njegove oblike. Ali ga želite začasno poslati na naš strežnik, da ga lahko vseeno prikažemo? - Izberi datoteko z naprave - Nobenih dokumentov ni bilo najdenih - Nedavni dokumenti Išči - Odpri Uredi Odstrani oglase Celozaslonski način Natisni Besedilo v govor - Pritisnite Nazaj za izhod iz celozaslonskega načina Tega programa ni bilo mogoče odpreti. Nazaj Naprej @@ -39,12 +29,7 @@ Naprej Nazaj Shrani - Pomoč Spodaj uredite dokument in pritisnite Shrani - Dobrodošli v \nOpenDocument Reader - OpenDocument Reader povsod omogoča ogled dokumentov, ki so shranjeni v obliki OpenDocument (.odt, .ods, .odp). - S programom OpenDocument Reader lahko berete in hitro iščete po dokumentih. - Ste našli še zadnjo napako v dokumentu, ki jo želite popraviti? Sedaj podpira tudi urejanje! Ta program se registrira, da privzeto odpre vse vrste datotek, da lahko podpira programe, kot je \"Samsung My Files\". Če vam ta značilnost povzroča težave, jo lahko tukaj izklopite. Branje … Začenjanje TTS-a … @@ -53,19 +38,6 @@ Premor. Končano. Tiskanje … - Na vaši napravi tiskanje ni na voljo. Nadgradite na novejšo različico Androida. - Odprite in berite vašo datoteko ODF na poti! - OpenDocument Reader omogoča ogled dokumentov, ki so shranjeni v obliki OpenDocument (.odt, .ods, .odp in .odg). Te datoteke se običajno ustvarijo z LibreOfficeom ali OpenOfficeom. Ta program omogoča odpiranje takih datotek tudi na vaši prenosni napravi, da jih lahko berete na poti. - Ste v dokumentu našli napako? Sedaj podpira tudi urejanje! - OpenDocument Reader ne omogoča samo branje dokumentov na vaši prenosni napravi, ampak tudi njihovo urejanje. Napake lahko popravite v trenutku, tudi na vlaku! - Berite svoje dokumente iz drugih programov - OpenDocument Reader podpira ogromno drugih programov, iz katerih lahko odpirate dokumente. Vam je prijatelj poslal predstavitev po Gmailu? Kliknite na prilogo in ta program jo bo v trenutku odprl! - ZAČNI Ne marate oglasov? Če jih želite odstraniti, kliknite tukaj! Odpri z drugim nameščenim programom na napravi: - - Odstrani oglase za vedno - Remove ads for a month - Začasno odstrani oglase - 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-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index d10863c6c2b6..a67ac9ea70a5 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -9,17 +9,12 @@ Dosya kaydedilemedi. Lütfen support@opendocument.app ile iletişime geçin Aygıt belleği yetersiz! Çok fazla resim var veya dosya çok büyük. Bu belge parola korumalı - Son kullanılan belgeler - Belgeyi şununla aç: Yükleniyor… Karşıya yükleniyor… Lütfen bekleyin, bu birkaç dakika sürebilir. Karşıya yüklenen dosyalar özeldir ve 24 saat sonra otomatik olarak silinir. Bu belgeyi açamıyoruz, çünkü biçimini desteklemiyoruz. Görüntüleyebilmeniz için geçici olarak sunucumuza yüklemek ister misiniz? Yüklenen dosyalar özeldir ve 24 saat sonra otomatik olarak silinir. - Belge bulunamadı - En son açılmış belgeler Belgelerde ara - Belgeyi Aç Birlikte aç... Belgeyi Paylaş Belgeyi düzenle @@ -36,10 +31,6 @@ Önceki Kaydet Aşağıdaki belgenizi düzenleyip kaydete tıklayın - OpenDocument Reader\'e \nHoşgeldiniz - OpenDocument Reader, nerede olursanız olun OpenDocument biçiminde (.odt, .ods, .odp ve .odg) depolanan belgeleri görüntülemenizi sağlar. - OpenDocument Reader ile belgelerinizi rahatça okuyabilir ve arayabilirsiniz. - Büyük sunumunuzdan önce düzeltilecek bir yazım hatası mı buldunuz? Artık değiştirmeyi de destekliyor! Bu uygulama \"Samsung My Files\"gibi uygulamaları desteklemek için varsayılan olarak tüm dosya türlerini açmak için kaydeder. Sizin için sorunlara neden olursa, bu özelliği buradan kapatabilirsiniz. Okunuyor… TTS başlatılıyor… @@ -53,11 +44,5 @@ Aygıtınızda kurulu başka bir uygulamayı kullanarak açın: Tamam - Kaydedilmemiş değişiklikleriniz var - Değişiklikler şimdi kaydedilsin mi? - Evet - Değişiklikleri iptal et İptal - Hata - Belgeye geri dön diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4ea2e44f51fb..8ab29f9ade9c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -5,31 +5,21 @@ Bu, desteklenen bir dosya biçimi gibi görünmüyor. Desteklenmeyen dosya biçimi. Başka bir uygulamada açmayı deneyin. Dosyanın görüntüsünden memnun değil misiniz? Bunun yerine başka bir uygulamada açın. - Aygıtda bu tür dosyaları destekleyen uygulama bulunamadı. Seçilen dosyayı kaydetmek için yer yok. Dosya kaydedilemedi. Lütfen support@opendocument.app ile iletişime geçin Aygıt belleği yetersiz! Çok fazla resim var veya dosya çok büyük. Belge parola korumalı - Belgeleri okumak ve kaydetmek için izin gerekli - Son kullanılan belgeler - Belgeyi şununla aç: Yükleniyor… Karşıya yükleniyor… Lütfen bekleyin, bu birkaç dakika sürebilir. Karşıya yüklenen dosyalar özeldir ve 24 saat sonra otomatik olarak silinir. - Reklamları küçük bir ücret karşılığında kaldırın: Bu belgeyi açamıyoruz, çünkü biçimini desteklemiyoruz. Görüntüleyebilmeniz için geçici olarak sunucumuza yüklemek ister misiniz? Yüklenen dosyalar özeldir ve 24 saat sonra otomatik olarak silinir. - Aygıttan dosya seçin - Belge nulunamadı - En son açılmış belgeler Belgelerde ara - Belgeyi Aç Belgeyi düzenle Reklamları kaldır Tam ekran görünümünü aç Belgeyi yazdır Konuşma metni - Tam ekran modundan çıkmak için Geri tuşuna basın Seçilen uygulama açılamıyor. Önceki Sonraki @@ -39,12 +29,7 @@ Sonraki Önceki Kaydet - Yardım Aşağıdaki belgenizi düzenleyip kaydete tıklayın - OpenDocument Reader\'e \nHoşgeldiniz - OpenDocument Reader, nerede olursanız olun OpenDocument biçiminde (.odt, .ods, .odp ve .odg) depolanan belgeleri görüntülemenizi sağlar. - OpenDocument Reader ile belgelerinizi rahatça okuyabilir ve arayabilirsiniz. - Büyük sunumunuzdan önce düzeltilecek bir yazım hatası mı buldunuz? Artık değiştirmeyi de destekliyor! Bu uygulama \"Samsung My Files\"gibi uygulamaları desteklemek için varsayılan olarak tüm dosya türlerini açmak için kaydeder. Sizin için sorunlara neden olursa, bu özelliği buradan kapatabilirsiniz. Okunuyor… TTS başlatılıyor… @@ -54,19 +39,6 @@ Bitti. Belge kaydedildi. Yazdırılıyor… - Aygıtınızda yazdırma özelliği yok. Lütfen Androidi daha yeni bir sürüme yükseltin. - ODF dosyanızı hareketliyken açın ve okuyun! - OpenDocument Reader, OpenDocument biçiminde (.odt, .ods, .odp ve .odg) depolanan belgeleri görüntülemenize olanak tanır. Bu dosyalar genellikle LibreOffice veya OpenOffice kullanılarak oluşturulur. Bu uygulama bu tür dosyaları mobil aygıtınızda da açmanıza izin verir, böylece onları hareket halindeyken okuyabilirsiniz. - Belgenizde bir yazım hatası mı buldunuz? Şimdi değiştirmeyi destekliyor! - OpenDocument Reader sadece mobil aygıtınızdaki belgeleri okumakla kalmaz, aynı zamanda onların değiştirilmesini de destekler. Yazı bir esitide hatta trende bile sabitdir! - Belgelerinizi diğer uygulamaların içinden okuyun - OpenDocument Reader, belgeleri açmak için diğer birçok uygulamayı destekler. Bir meslektaşınız Gmail aracılığıyla bir sunum mu gönderdi? Eki tıklayın bu uygulama hemen açılacak! - BAŞLAT Okumak için daha fazla alana mı ihtiyacınız var? Menüden reklamları ücretsiz olarak kaldırın. Aygıtınızda kurulu başka bir uygulamayı kullanarak açın: - - Reklamları sürekli kaldır - Remove ads for a month - Kısa bir video izleyerek reklamları geçici olarak ücretsiz kaldırın - diff --git a/app/src/main/res/values-v29/themes.xml b/app/src/main/res/values-v29/themes.xml deleted file mode 100644 index 53dbc731bec3..000000000000 --- a/app/src/main/res/values-v29/themes.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-v35/themes.xml b/app/src/main/res/values-v35/themes.xml index f4136c3da446..0b1a0c48bff3 100644 --- a/app/src/main/res/values-v35/themes.xml +++ b/app/src/main/res/values-v35/themes.xml @@ -1,17 +1,14 @@ - + 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-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d1fd736f1c1b..df1215ed25d7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -9,17 +9,12 @@ 无法保存文件。请联系 support@opendocument.app 设备内存不足!可能是因为文件中的图片太多了,也可能是文件体积过大。 此文档受密码保护。 - 最近文档 - 通过…打开文档: 正在加载… 正在上传… 请稍候,可能需要几分钟。 被上传的文件是私密的,24 小时后文件会被自动删除。 我们无法打开此文档,因为本应用不支持其格式。是否要暂时将其上传到我们的服务器, 以便我们设法在未来的版本中允许本应用显示它?上传的文件依然归您所有,并在会在24小时后自动删除。 - 找不到文档 - 最近打开的文档 在文档中搜索 - 打开文档 打开方式... 分享文档 编辑文档 @@ -37,10 +32,6 @@ 上一个 保存 编辑您的文档,然后点击保存 - 欢迎使用\nOpenDocument 阅读器 - OpenDocument Reader 允许您查看存储为 OpenDocument 格式(.odt、.ods、.ods、.odp 和 .odg)的文档。 - 通过 OpenDocument 阅读器,您可以简单利落地阅读、搜索您的文档。 - 在您某次重大的演讲前发现了演示文档中的最后一个拼写错误?现已支持修改文档! ,此应用程序默认注册为支持打开所有类型的文件,以支持类似三星设备自带的 \"我的文件\" 等有问题的应用程序。如果此功能会给您带来干扰或问题,您可以在此处关闭它。 正在读取… 正在初始化 TTS 引擎… @@ -54,11 +45,5 @@ 使用设备上的其他应用打开: 确认 - 你有未保存的更改 - 现在保存它们吗? - - 放弃更改 取消 - 出错 - 返回文档 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a6ca1022f652..0ecdc66073e2 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,29 +4,19 @@ 找不到此文件。或许它已经不在那里了? 此文件格式似乎不受支持。 对文件的显示效果不满意?在另一个应用中打开它。 - 在本设备上找不到支持此类型的文件的应用。 设备内存不足!可能是因为文件中的图片太多了,也可能是文件体积过大。 此文档受密码保护。 - 需要权限以便存取文档 - 最近文档 - 通过…打开文档: 正在加载… 正在上传… 请稍候,可能需要几分钟。 被上传的文件是私密的,24 小时后文件会被自动删除。 - 支付一点小费以移除广告: 我们无法打开此文档,因为本应用不支持其格式。是否要暂时将其上传到我们的服务器, 以便我们设法在未来的版本中允许本应用显示它?上传的文件依然归您所有,并在会在24小时后自动删除。 - 从设备中选择文件 - 找不到文档 - 最近打开的文档 在文档中搜索 - 打开文档 编辑文档 移除广告 打开全屏模式 打印文档 文字转语音 - 按返回键即可退出全屏模式 无法打开选择的应用。 上一个 下一个 @@ -36,12 +26,7 @@ 下一个 上一个 保存 - 帮助 编辑您的文档,然后点击保存 - 欢迎使用\nOpenDocument 阅读器 - OpenDocument Reader 允许您查看存储为 OpenDocument 格式(.odt、.ods、.ods、.odp 和 .odg)的文档。 - 通过 OpenDocument 阅读器,您可以简单利落地阅读、搜索您的文档。 - 在您某次重大的演讲前发现了演示文档中的最后一个拼写错误?现已支持修改文档! ,此应用程序默认注册为支持打开所有类型的文件,以支持类似三星设备自带的 \"我的文件\" 等有问题的应用程序。如果此功能会给您带来干扰或问题,您可以在此处关闭它。 正在读取… 正在初始化 TTS 引擎… @@ -50,19 +35,6 @@ 已暂停。 播放结束。 正在打印… - 您的设备不支持打印。请将 Android 系统升级至更加新的版本(Android 4.4 以上)。 - 随时随地打开并阅读您的 ODF 文件! - 您可以使用 OpenDocument 阅读器查看以 OpenDocument 格式(.odt, .ods, .odp 和 .odg)储存的文档文件。这些文件通常是使用 LibreOffice 或 OpenOffice 创建的。本应用允许您在移动设备上打开这些文档,助您随时随地阅读它们。 - 发现文件存在一处文稿错误?支持修改! - 超级文件管理大师不仅允许用户在移动设备上阅读文件,同时还支持文件修改。用户即使在火车上也可轻松修改文稿错误。 - 通过其他app阅读文件 - 超级文件管理大师支持通过大量其他的app打开文件。一位同事通过Gmail发来一份演示文稿?只需点击附件,立刻将其打开! - 开始 需要更多空间以便阅读?通过菜单购买删除广告。 使用设备上的其他应用打开: - - 永久移除广告 - Remove ads for a month - 通过观看短视频并暂时免费移除广告 - diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml new file mode 100644 index 000000000000..4c1d84b4af9e --- /dev/null +++ b/app/src/main/res/values/bools.xml @@ -0,0 +1,10 @@ + + + + + true + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000000..012ea56e1d19 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,57 @@ + + + + + #00658F + #FFFFFF + #C7E7FF + #001E2F + + #4F616E + #FFFFFF + #D2E5F5 + #0B1D29 + + #FCFCFF + #191C1E + #DDE3EA + #41484D + #ECEFF3 + + #63597C + #FFFFFF + #E9DDFF + #1F1635 + + + #2E3133 + #F0F1F3 + #86CFFF + + #71787E + #C1C7CE + + #BA1A1A + #FFFFFF + #FFDAD6 + #410002 + + + #FFFFFF + + + #52000000 + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e31b696c94be..34df3566eee8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,17 +11,12 @@ File could not be saved. Please contact support@opendocument.app Device out of memory! Too many pictures, or file too big. This document is password-protected - Recent documents - Open document via: Loading… Uploading… Please wait, this could take a few minutes. Uploaded files are private and automatically deleted after 24 hours. We aren\'t able to open this document, because we don\'t support its format. Do you want to upload it to our server temporarily, so we can display it for you anyway? Uploaded files are private and automatically deleted after 24 hours. - No documents found - Recently opened documents Search in document - Open document Open with... Share document Edit document @@ -40,11 +35,23 @@ 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! 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. + Recent + Folders + Settings + Open all file types + Removed from recent + Folder removed + Undo + Open a file + Add a folder… + This folder can\'t be remembered. Try another one. + No documents in this folder. + Up one level + Nothing here yet + Open a document to get started. It will show up here next time. + More actions + Close actions Reading… Initializing TTS… Ready! @@ -58,12 +65,6 @@ OK - You have unsaved changes - Save them now? - Yes - Discard changes Cancel - Error - Back to documents \ No newline at end of file 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/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index aefcfee01400..0dec01cdbb63 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,12 +1,59 @@ - - \ No newline at end of file +